diff --git a/.gitignore b/.gitignore index 614beed..6a22854 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,5 @@ .idea/ .tmp/ coverage/ -esm/ -lib/ +dist/ node_modules/ -reports/ -webpack.stats.json diff --git a/README.md b/README.md index c1ecacf..3ddb5af 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,11 @@ export default function App() { ## Props **src** {string} - **required**. -The SVG file you want to load. It can be a require, URL or a string (base64 or url encoded). -_If you are using create-react-app and your file resides in the `public` directory you can use the path directly without require._ +The SVG file you want to load. It can be a require, URL, or a string (base64 or URL encoded). +_If you are using create-react-app and your file resides in the `public` directory, you can use the path directly without require._ **baseURL** {string} -An URL to prefix each ID in case you are using the `` tag and `uniquifyIDs`. +An URL to prefix each ID in case you use the `` tag and `uniquifyIDs`. **children** {ReactNode} The fallback content in case of a fetch error or unsupported browser. @@ -59,7 +59,8 @@ The fallback content in case of a fetch error or unsupported browser. ``` **cacheRequests** {boolean} ▶︎ `true` -Cache remote SVGs. +Cache remote SVGs. +Starting in version 4.x, you can also cache the files permanently, read more [below](#caching). **description** {string} A description for your SVG. It will override an existing `` tag. @@ -100,7 +101,7 @@ This will receive a single argument with: **onLoad** {function}. A callback to be invoked upon successful load. -This will receive 2 arguments: the `src` prop and a `hasCache` boolean +This will receive 2 arguments: the `src` prop and an `isCached` boolean **preProcessor** {function} ▶︎ `string` A function to process the contents of the SVG text before parsing. @@ -126,7 +127,7 @@ Create unique IDs for each icon. description="The React logo" loader={Loading...} onError={(error) => console.log(error.message)} - onLoad={(src, hasCache) => console.log(src, hasCache)} + onLoad={(src, isCached) => console.log(src, isCached)} preProcessor={(code) => code.replace(/fill=".*?"/g, 'fill="currentColor"')} src="https://cdn.svgporn.com/logos/react.svg" title="React" @@ -137,23 +138,37 @@ Create unique IDs for each icon. ## Caching -The internal cache is exported as `cacheStore` if you need to debug or pre-cache some files. -⚠️ Use it at your own risk. +You can use the browser's cache to store the SVGs permanently. +To set it up, wrap your app with the cache provider: + +```typescript +import { createRoot } from 'react-dom/client'; +import CacheProvider from 'react-inlinesvg/provider'; +import App from './App'; + +createRoot(document.getElementById('root')!).render( + + + +); +``` + +> Be aware of the limitations of the [Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache). ## Browser Support 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. -If you need to support legacy browsers you'll need to include a polyfiil for `fetch` and `Number.isNaN` in your app. Take a look at [react-app-polyfill](https://www.npmjs.com/package/react-app-polyfill) or [polyfill.io](https://polyfill.io/v3/). +If you need to support legacy browsers, include a polyfill for `fetch` and `Number.isNaN` in your app. Take a look at [react-app-polyfill](https://www.npmjs.com/package/react-app-polyfill) or [polyfill.io](https://polyfill.io/v3/). ## CORS -If you are loading remote SVGs, you'll need to make sure it has [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) support. +If you are loading remote SVGs, you must ensure it has [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) support. -## Why you need this package? +## Why do you need this package? -One of the reasons SVGs are awesome is because you can style them with CSS. -Unfortunately, this winds up not being too useful in practice because the style element has to be in the same document. This leaves you with three bad options: +One of the reasons SVGs are awesome is that you can style them with CSS. +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: 1. Embed the CSS in the SVG document - Can't use your CSS preprocessors (LESS, SASS) @@ -178,5 +193,5 @@ The SVG [``](http://css-tricks.com/svg-use-external-source) element can be ## Credits -Thanks to [@matthewwithanm](https://github.com/matthewwithanm) for creating this component and so kindly transfer it to me. -I'll definitely keep the good work! ❤️ +Thanks to [@matthewwithanm](https://github.com/matthewwithanm) for creating this component and so kindly transferring it to me. +I'll definitely keep up the good work! ❤️ diff --git a/demo/package.json b/demo/package.json index c19aeb9..1d029b5 100755 --- a/demo/package.json +++ b/demo/package.json @@ -1,19 +1,18 @@ { "name": "react-inlinesvg-demo", - "version": "3.0.2", + "version": "4.0.0", "description": "An SVG loader component for ReactJS", "keywords": [], "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", - "react-inlinesvg": "^3.0.2", + "react-inlinesvg": "latest", "react-scripts": "^5.0.1", - "styled-components": "^5.3.6", + "styled-components": "^6.0.7", "@types/node": "^18.14.1", - "@types/react": "^18.0.28", - "@types/react-dom": "^18.0.11", - "@types/styled-components": "^5.1.26", - "typescript": "^4.9.5" + "@types/react": "^18.2.19", + "@types/react-dom": "^18.2.7", + "typescript": "^5.1.6" }, "scripts": { "start": "react-scripts start", diff --git a/package-lock.json b/package-lock.json index 109be93..593a882 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@gilbarbara/prettier-config": "^1.0.0", "@gilbarbara/tsconfig": "^0.1.1", "@size-limit/preset-small-lib": "^8.2.6", + "@swc/core": "^1.3.75", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^14.0.0", "@types/exenv": "^1.2.0", @@ -25,8 +26,9 @@ "@types/jest": "^29.5.3", "@types/node": "^20.4.8", "@types/node-fetch": "^2.6.4", - "@types/react": "^18.2.18", + "@types/react": "^18.2.19", "@types/react-dom": "^18.2.7", + "browser-cache-mock": "^0.1.7", "cross-fetch": "^4.0.0", "del-cli": "^5.0.0", "http-server": "^14.1.1", @@ -44,6 +46,7 @@ "start-server-and-test": "^2.0.0", "ts-jest": "^29.1.1", "ts-node": "^10.9.1", + "tsup": "^7.2.0", "typescript": "^5.1.6" }, "peerDependencies": { @@ -2099,6 +2102,200 @@ "size-limit": "8.2.6" } }, + "node_modules/@swc/core": { + "version": "1.3.75", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.75.tgz", + "integrity": "sha512-YLqd5oZVnaOq/OzkjRSsJUQqAfKYiD0fzUyVUPVlNNCoQEfVfSMcXH80hLmYe9aDH0T/a7qEMjWyIr/0kWqy1A==", + "dev": true, + "hasInstallScript": true, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.3.75", + "@swc/core-darwin-x64": "1.3.75", + "@swc/core-linux-arm-gnueabihf": "1.3.75", + "@swc/core-linux-arm64-gnu": "1.3.75", + "@swc/core-linux-arm64-musl": "1.3.75", + "@swc/core-linux-x64-gnu": "1.3.75", + "@swc/core-linux-x64-musl": "1.3.75", + "@swc/core-win32-arm64-msvc": "1.3.75", + "@swc/core-win32-ia32-msvc": "1.3.75", + "@swc/core-win32-x64-msvc": "1.3.75" + }, + "peerDependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.3.75", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.75.tgz", + "integrity": "sha512-anDnx9L465lGbjB2mvcV54NGHW6illr0IDvVV7JmkabYUVneaRdQvTr0tbHv3xjHnjrK1wuwVOHKV0LcQF2tnQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.3.75", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.75.tgz", + "integrity": "sha512-dIHDfrLmeZfr2xwi1whO7AmzdI3HdamgvxthaL+S8L1x8TeczAZEvsmZTjy3s8p3Va4rbGXcb3+uBhmfkqCbfw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.3.75", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.75.tgz", + "integrity": "sha512-qeJmvMGrjC6xt+G0R4kVqqxvlhxJx7tTzhcEoWgLJnfvGZiF6SJdsef4OSM7HuReXrlBoEtJbfGPrLJtbV+C0w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.3.75", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.75.tgz", + "integrity": "sha512-sqA9JqHEJBF4AdNuwo5zRqq0HC3l31SPsG9zpRa4nRzG5daBBJ80H7fi6PZQud1rfNNq+Q08gjYrdrxwHstvjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.3.75", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.75.tgz", + "integrity": "sha512-95rQT5xTAL3eKhMJbJbLsZHHP9EUlh1rcrFoLf0gUApoVF8g94QjZ9hYZiI72mMP5WPjgTEXQVnVB9O2GxeaLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.3.75", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.75.tgz", + "integrity": "sha512-If7UpAhnPduMmtC+TSgPpZ1UXZfp2hIpjUFxpeCmHHYLS6Fn/2GZC5hpEiu+wvFJF0hzPh93eNAHa9gUxGUG+w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.3.75", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.75.tgz", + "integrity": "sha512-HOhxX0YNHTElCZqIviquka3CGYTN8rSQ6BdFfSk/K0O+ZEHx3qGte0qr+gGLPF/237GxreUkp3OMaWKuURtuCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.3.75", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.75.tgz", + "integrity": "sha512-7QPI+mvBXAerVfWahrgBNe+g7fK8PuetxFnZSEmXUcDXvWcdJXAndD7GjAJzbDyjQpLKHbsDKMiHYvfNxZoN/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.3.75", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.75.tgz", + "integrity": "sha512-EfABCy4Wlq7O5ShWsm32FgDkSjyeyj/SQ4wnUIvWpkXhgfT1iNXky7KRU1HtX+SmnVk/k/NnabVZpIklYbjtZA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.3.75", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.75.tgz", + "integrity": "sha512-cTvP0pOD9C3pSp1cwtt85ZsrUkQz8RZfSPhM+jCGxKxmoowDCnInoOQ4Ica/ehyuUnQ4/IstSdYtYpO5yzPDJg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, "node_modules/@testing-library/dom": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.1.tgz", @@ -2434,9 +2631,9 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.18.tgz", - "integrity": "sha512-da4NTSeBv/P34xoZPhtcLkmZuJ+oYaCxHmyHzwaDQo9RQPBeXV+06gEk2FpqEcsX9XrnNLvRpVh6bdavDSjtiQ==", + "version": "18.2.19", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.19.tgz", + "integrity": "sha512-e2S8wmY1ePfM517PqCG80CcE48Xs5k0pwJzuDZsfE8IZRRBfOMCF+XqnFxu6mWtyivum1MQm4aco+WIt6Coimw==", "dev": true, "dependencies": { "@types/prop-types": "*", @@ -3039,6 +3236,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -3459,6 +3662,12 @@ "node": ">=8" } }, + "node_modules/browser-cache-mock": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/browser-cache-mock/-/browser-cache-mock-0.1.7.tgz", + "integrity": "sha512-+0bM5cjnj8Z8caAYBe1A9AA30juciI8fBHhw+aPBSSGL89UHPUcBA/E6CrQAecC635MEfx8qadTnaocEwE2q8Q==", + "dev": true + }, "node_modules/browserslist": { "version": "4.21.10", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.10.tgz", @@ -3545,6 +3754,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bundle-require": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-4.0.1.tgz", + "integrity": "sha512-9NQkRHlNdNpDBGmLpngF3EFDcwodhMUuLz9PaWYciVcQF9SE4LFjM2DB/xV1Li5JiuDMv7ZUWuC3rGbqR0MAXQ==", + "dev": true, + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.17" + } + }, "node_modules/bytes-iec": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/bytes-iec/-/bytes-iec-3.1.1.tgz", @@ -3554,6 +3778,15 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -8485,6 +8718,15 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8697,6 +8939,15 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -8740,6 +8991,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -9062,6 +9319,17 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", @@ -9688,6 +9956,35 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/postcss-load-config": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.1.tgz", + "integrity": "sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==", + "dev": true, + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^2.1.1" + }, + "engines": { + "node": ">= 14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10282,6 +10579,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rollup": { + "version": "3.27.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.27.2.tgz", + "integrity": "sha512-YGwmHf7h2oUHkVBT248x0yt6vZkYQ3/rvE5iQuVBh3WO8GcJ6BNeOkpoX1yMHIiBm18EMLjBPIoUDkhgnyxGOQ==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/run-applescript": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", @@ -10842,6 +11155,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sucrase": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", + "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "7.1.6", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -10971,6 +11335,27 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -11043,6 +11428,15 @@ "node": ">=12" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/trim-newlines": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.0.2.tgz", @@ -11067,6 +11461,12 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, "node_modules/ts-jest": { "version": "29.1.1", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz", @@ -11201,6 +11601,98 @@ "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==", "dev": true }, + "node_modules/tsup": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-7.2.0.tgz", + "integrity": "sha512-vDHlczXbgUvY3rWvqFEbSqmC1L7woozbzngMqTtL2PGBODTtWlRwGDDawhvWzr5c1QjKe4OAKqJGfE1xeXUvtQ==", + "dev": true, + "dependencies": { + "bundle-require": "^4.0.0", + "cac": "^6.7.12", + "chokidar": "^3.5.1", + "debug": "^4.3.1", + "esbuild": "^0.18.2", + "execa": "^5.0.0", + "globby": "^11.0.3", + "joycon": "^3.0.1", + "postcss-load-config": "^4.0.1", + "resolve-from": "^5.0.0", + "rollup": "^3.2.5", + "source-map": "0.8.0-beta.0", + "sucrase": "^3.20.3", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=16.14" + }, + "peerDependencies": { + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/tsup/node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "dev": true, + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tsup/node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/tsup/node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true + }, + "node_modules/tsup/node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, "node_modules/tsutils": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", @@ -11809,6 +12301,15 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/yaml": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", + "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", diff --git a/package.json b/package.json index 36b4c5a..09496bb 100644 --- a/package.json +++ b/package.json @@ -22,14 +22,28 @@ "url": "https://github.com/gilbarbara/react-inlinesvg/issues" }, "homepage": "https://github.com/gilbarbara/react-inlinesvg#readme", - "main": "lib/index.js", - "module": "esm/index.js", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./provider": { + "import": "./dist/provider.mjs", + "default": "./dist/provider.js" + } + }, "files": [ - "esm", - "lib", + "dist", "src" ], - "types": "esm", + "typesVersions": { + "*": { + "provider": ["dist/provider.d.ts"] + } + }, + "types": "dist/index.d.ts", "sideEffects": false, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" @@ -44,6 +58,7 @@ "@gilbarbara/prettier-config": "^1.0.0", "@gilbarbara/tsconfig": "^0.1.1", "@size-limit/preset-small-lib": "^8.2.6", + "@swc/core": "^1.3.75", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^14.0.0", "@types/exenv": "^1.2.0", @@ -51,8 +66,9 @@ "@types/jest": "^29.5.3", "@types/node": "^20.4.8", "@types/node-fetch": "^2.6.4", - "@types/react": "^18.2.18", + "@types/react": "^18.2.19", "@types/react-dom": "^18.2.7", + "browser-cache-mock": "^0.1.7", "cross-fetch": "^4.0.0", "del-cli": "^5.0.0", "http-server": "^14.1.1", @@ -70,15 +86,13 @@ "start-server-and-test": "^2.0.0", "ts-jest": "^29.1.1", "ts-node": "^10.9.1", + "tsup": "^7.2.0", "typescript": "^5.1.6" }, "scripts": { - "build": "npm run clean && npm run build:cjs && npm run build:esm", - "build:cjs": "tsc", - "build:esm": "tsc -m es6 --outDir esm", - "clean": "del lib/* && del esm/*", - "watch:cjs": "npm run build:cjs -- -w", - "watch:esm": "npm run build:esm -- -w", + "build": "npm run clean && tsup", + "watch": "tsup --watch", + "clean": "del dist/*", "start": "http-server test/__fixtures__ -p 1337 --cors", "test": "start-server-and-test start http://127.0.0.1:1337 test:coverage", "test:coverage": "jest --coverage --bail", @@ -90,6 +104,19 @@ "prepublishOnly": "npm run validate", "prepare": "husky install" }, + "tsup": { + "dts": true, + "entry": [ + "src/index.tsx", + "src/provider.tsx" + ], + "format": [ + "cjs", + "esm" + ], + "sourcemap": true, + "splitting": false + }, "eslintConfig": { "extends": [ "@gilbarbara/eslint-config" @@ -111,14 +138,14 @@ "prettier": "@gilbarbara/prettier-config", "size-limit": [ { - "name": "lib", - "path": "./lib/index.js", - "limit": "9 kB" + "name": "commonjs", + "path": "./dist/index.js", + "limit": "11 KB" }, { "name": "esm", - "path": "./esm/index.js", - "limit": "9 kB" + "path": "./dist/index.mjs", + "limit": "9 KB" } ] } diff --git a/sonar-project.properties b/sonar-project.properties index 85cf299..af72527 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,4 +2,5 @@ sonar.projectKey=gilbarbara_react-inlinesvg sonar.organization=gilbarbara-github sonar.source=./src sonar.javascript.lcov.reportPaths=./coverage/lcov.info +sonar.exclusions=**/demo/**/*.*,**/test/**/*.* sonar.coverage.exclusions=**/test/**/*.*,**/jest.config.js diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..45720e0 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,164 @@ +import { CACHE_MAX_RETRIES, CACHE_NAME, STATUS } from './config'; +import { request, sleep } from './helpers'; +import { StorageItem } from './types'; + +export default class CacheStore { + private cacheApi: Cache | undefined; + private readonly cacheStore: Map; + private readonly subscribers: Array<() => void> = []; + private readonly usePersistentCache: boolean; + public isReady = false; + + constructor() { + this.cacheStore = new Map(); + + this.usePersistentCache = + 'REACT_INLINESVG_PERSISTENT_CACHE' in window && !!window.REACT_INLINESVG_PERSISTENT_CACHE; + + if (this.usePersistentCache) { + caches.open(CACHE_NAME).then(cache => { + this.cacheApi = cache; + this.isReady = true; + + this.subscribers.forEach(callback => callback()); + }); + } else { + this.isReady = true; + } + } + + public onReady(callback: () => void) { + if (this.isReady) { + callback(); + } else { + this.subscribers.push(callback); + } + } + + public async get(url: string, fetchOptions?: RequestInit) { + await (this.usePersistentCache + ? this.fetchAndAddToPersistentCache(url, fetchOptions) + : this.fetchAndAddToInternalCache(url, fetchOptions)); + + return this.cacheStore.get(url)?.content ?? ''; + } + + public set(url: string, data: StorageItem) { + this.cacheStore.set(url, data); + } + + public isCached(url: string) { + return this.cacheStore.get(url)?.status === STATUS.LOADED; + } + + private async fetchAndAddToInternalCache(url: string, fetchOptions?: RequestInit) { + const cache = this.cacheStore.get(url); + + if (cache?.status === STATUS.LOADING) { + await this.handleLoading(url, async () => { + this.cacheStore.set(url, { content: '', status: STATUS.IDLE }); + await this.fetchAndAddToInternalCache(url, fetchOptions); + }); + + return; + } + + if (!cache?.content) { + this.cacheStore.set(url, { content: '', status: STATUS.LOADING }); + + try { + const content = await request(url, fetchOptions); + + this.cacheStore.set(url, { content, status: STATUS.LOADED }); + } catch (error: any) { + this.cacheStore.set(url, { content: '', status: STATUS.FAILED }); + throw error; + } + } + } + + private async fetchAndAddToPersistentCache(url: string, fetchOptions?: RequestInit) { + const cache = this.cacheStore.get(url); + + if (cache?.status === STATUS.LOADED) { + return; + } + + if (cache?.status === STATUS.LOADING) { + await this.handleLoading(url, async () => { + this.cacheStore.set(url, { content: '', status: STATUS.IDLE }); + await this.fetchAndAddToPersistentCache(url, fetchOptions); + }); + + return; + } + + this.cacheStore.set(url, { content: '', status: STATUS.LOADING }); + + const data = await this.cacheApi?.match(url); + + if (data) { + const content = await data.text(); + + this.cacheStore.set(url, { content, status: STATUS.LOADED }); + + return; + } + + try { + await this.cacheApi?.add(new Request(url, fetchOptions)); + + const response = await this.cacheApi?.match(url); + const content = (await response?.text()) ?? ''; + + this.cacheStore.set(url, { content, status: STATUS.LOADED }); + } catch (error: any) { + this.cacheStore.set(url, { content: '', status: STATUS.FAILED }); + throw error; + } + } + + private async handleLoading(url: string, callback: () => Promise) { + let retryCount = 0; + + // eslint-disable-next-line no-await-in-loop + while (this.cacheStore.get(url)?.status === STATUS.LOADING && retryCount < CACHE_MAX_RETRIES) { + // eslint-disable-next-line no-await-in-loop + await sleep(0.1); + retryCount += 1; + } + + if (retryCount >= CACHE_MAX_RETRIES) { + await callback(); + } + } + + public keys(): Array { + return [...this.cacheStore.keys()]; + } + + public data(): Array> { + return [...this.cacheStore.entries()].map(([key, value]) => ({ [key]: value })); + } + + public async delete(url: string) { + if (this.cacheApi) { + await this.cacheApi.delete(url); + } + + this.cacheStore.delete(url); + } + + public async clear() { + if (this.cacheApi) { + const keys = await this.cacheApi.keys(); + + for (const key of keys) { + // eslint-disable-next-line no-await-in-loop + await this.cacheApi.delete(key); + } + } + + this.cacheStore.clear(); + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3f1de06 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,11 @@ +export const CACHE_NAME = 'react-inlinesvg'; +export const CACHE_MAX_RETRIES = 10; + +export const STATUS = { + IDLE: 'idle', + LOADING: 'loading', + LOADED: 'loaded', + FAILED: 'failed', + READY: 'ready', + UNSUPPORTED: 'unsupported', +} as const; diff --git a/src/helpers.ts b/src/helpers.ts index 1e12268..7a25691 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -2,15 +2,6 @@ import { canUseDOM as canUseDOMFlag } from 'exenv'; import type { PlainObject } from './types'; -export const STATUS = { - IDLE: 'idle', - LOADING: 'loading', - LOADED: 'loaded', - FAILED: 'failed', - READY: 'ready', - UNSUPPORTED: 'unsupported', -} as const; - export function canUseDOM(): boolean { return canUseDOMFlag; } @@ -19,6 +10,28 @@ export function isSupportedEnvironment(): boolean { return supportsInlineSVG() && typeof window !== 'undefined' && window !== null; } +export async function request(url: string, options?: RequestInit) { + const response = await fetch(url, options); + const contentType = response.headers.get('content-type'); + const [fileType] = (contentType || '').split(/ ?; ?/); + + if (response.status > 299) { + throw new Error('Not found'); + } + + if (!['image/svg+xml', 'text/plain'].some(d => fileType.includes(d))) { + throw new Error(`Content type isn't valid: ${fileType}`); + } + + return response.text(); +} + +export function sleep(seconds = 1) { + return new Promise(resolve => { + setTimeout(resolve, seconds * 1000); + }); +} + export function supportsInlineSVG(): boolean { /* istanbul ignore next */ if (!document) { diff --git a/src/index.tsx b/src/index.tsx index 880dec9..fcb9051 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,12 +1,15 @@ import * as React from 'react'; import convert from 'react-from-dom'; -import { canUseDOM, isSupportedEnvironment, omit, randomString, STATUS } from './helpers'; -import { FetchError, Props, State, Status, StorageItem } from './types'; +import CacheStore from './cache'; +import { STATUS } from './config'; +import { canUseDOM, isSupportedEnvironment, omit, randomString, request } from './helpers'; +import { FetchError, Props, State, Status } from './types'; -export const cacheStore: { [key: string]: StorageItem } = Object.create(null); +// eslint-disable-next-line import/no-mutable-exports +export let cacheStore: CacheStore; -export default class InlineSVG extends React.PureComponent { +class ReactInlineSVG extends React.PureComponent { private readonly hash: string; private isActive = false; private isInitialized = false; @@ -22,7 +25,7 @@ export default class InlineSVG extends React.PureComponent { this.state = { content: '', element: null, - hasCache: !!props.cacheRequests && !!cacheStore[props.src], + isCached: !!props.cacheRequests && cacheStore.isCached(props.src), status: STATUS.IDLE, }; @@ -66,13 +69,13 @@ export default class InlineSVG extends React.PureComponent { return; } - const { hasCache, status } = this.state; + const { isCached, status } = this.state; const { onLoad, src } = this.props; if (previousState.status !== STATUS.READY && status === STATUS.READY) { /* istanbul ignore else */ if (onLoad) { - onLoad(src, hasCache); + onLoad(src, isCached); } } @@ -91,6 +94,14 @@ export default class InlineSVG extends React.PureComponent { this.isActive = false; } + private fetchContent = async () => { + const { fetchOptions, src } = this.props; + + const content: string = await request(src, fetchOptions); + + this.handleLoad(content); + }; + private getElement() { try { const node = this.getNode() as Node; @@ -178,7 +189,7 @@ export default class InlineSVG extends React.PureComponent { this.setState( { content, - hasCache, + isCached: hasCache, status: STATUS.LOADED, }, this.getElement, @@ -193,18 +204,11 @@ export default class InlineSVG extends React.PureComponent { { content: '', element: null, - hasCache: false, + isCached: false, status: STATUS.LOADING, }, - () => { - const { cacheRequests, src } = this.props; - const cache = cacheRequests && cacheStore[src]; - - if (cache && cache.status === STATUS.LOADED) { - this.handleLoad(cache.content, true); - - return; - } + async () => { + const { cacheRequests, fetchOptions, src } = this.props; const dataURI = src.match(/^data:image\/svg[^,]*?(;base64)?,(.*)/u); let inlineSrc; @@ -221,7 +225,17 @@ export default class InlineSVG extends React.PureComponent { return; } - this.request(); + try { + if (cacheRequests) { + const content = await cacheStore.get(src, fetchOptions); + + this.handleLoad(content, true); + } else { + await this.fetchContent(); + } + } catch (error: any) { + this.handleError(error); + } }, ); } @@ -238,65 +252,6 @@ export default class InlineSVG extends React.PureComponent { return content; } - private request = async () => { - const { cacheRequests, fetchOptions, src } = this.props; - - if (cacheRequests) { - cacheStore[src] = { content: '', status: STATUS.LOADING }; - } - - try { - const response = await fetch(src, fetchOptions); - const contentType = response.headers.get('content-type'); - const [fileType] = (contentType || '').split(/ ?; ?/); - - if (response.status > 299) { - throw new Error('Not found'); - } - - if (!['image/svg+xml', 'text/plain'].some(d => fileType.includes(d))) { - throw new Error(`Content type isn't valid: ${fileType}`); - } - - const content: string = await response.text(); - const { src: currentSrc } = this.props; - - // the current src don't match the previous one, skipping... - if (src !== currentSrc) { - if (cacheStore[src].status === STATUS.LOADING) { - delete cacheStore[src]; - } - - return; - } - - this.handleLoad(content); - - /* istanbul ignore else */ - if (cacheRequests) { - const cache = cacheStore[src]; - - /* istanbul ignore else */ - if (cache) { - cache.content = content; - cache.status = STATUS.LOADED; - } - } - } catch (error: any) { - this.handleError(error); - - /* istanbul ignore else */ - if (cacheRequests) { - const cache = cacheStore[src]; - - /* istanbul ignore else */ - if (cache) { - delete cacheStore[src]; - } - } - } - }; - private updateSVGAttributes(node: SVGSVGElement): SVGSVGElement { const { baseURL = '', uniquifyIDs } = this.props; const replaceableAttributes = ['id', 'href', 'xlink:href', 'xlink:role', 'xlink:arcrole']; @@ -377,4 +332,30 @@ export default class InlineSVG extends React.PureComponent { } } +export default function InlineSVG(props: Props) { + if (!cacheStore) { + cacheStore = new CacheStore(); + } + + const { loader } = props; + const hasCallback = React.useRef(false); + const [isReady, setReady] = React.useState(cacheStore.isReady); + + React.useEffect(() => { + if (!hasCallback.current) { + cacheStore.onReady(() => { + setReady(true); + }); + + hasCallback.current = true; + } + }, []); + + if (!isReady) { + return loader; + } + + return ; +} + export * from './types'; diff --git a/src/provider.tsx b/src/provider.tsx new file mode 100644 index 0000000..6ddd28a --- /dev/null +++ b/src/provider.tsx @@ -0,0 +1,25 @@ +import { ReactNode, useEffect } from 'react'; + +interface Props { + children: ReactNode; +} + +declare global { + interface Window { + REACT_INLINESVG_PERSISTENT_CACHE?: boolean; + } +} + +export default function CacheProvider({ children }: Props) { + window.REACT_INLINESVG_PERSISTENT_CACHE = true; + + useEffect(() => { + window.REACT_INLINESVG_PERSISTENT_CACHE = true; + + return () => { + delete window.REACT_INLINESVG_PERSISTENT_CACHE; + }; + }, []); + + return children; +} diff --git a/src/types.ts b/src/types.ts index d573cb2..067c94e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,6 @@ import * as React from 'react'; -import { STATUS } from './helpers'; +import { STATUS } from './config'; export type ErrorCallback = (error: Error | FetchError) => void; export type LoadCallback = (src: string, isCached: boolean) => void; @@ -27,7 +27,7 @@ export interface Props extends Omit, 'onLoad' | 'onEr export interface State { content: string; element: React.ReactNode; - hasCache: boolean; + isCached: boolean; status: Status; } diff --git a/test/__snapshots__/index.spec.tsx.snap b/test/__snapshots__/index.spec.tsx.snap index 8879abf..66575bc 100644 --- a/test/__snapshots__/index.spec.tsx.snap +++ b/test/__snapshots__/index.spec.tsx.snap @@ -283,7 +283,7 @@ exports[`react-inlinesvg basic functionality should render a base64 src 1`] = ` exports[`react-inlinesvg basic functionality should render a loader 1`] = `
`; @@ -2422,38 +2422,6 @@ exports[`react-inlinesvg basic functionality should uniquify ids with a custom u `; -exports[`react-inlinesvg cached requests should handle cached entries with loading status 1`] = ` -{ - "http://127.0.0.1:1337/react.svg": { - "content": - - React - - - -, - "status": "loaded", - }, -} -`; - -exports[`react-inlinesvg cached requests should request an SVG only once with cacheRequests prop 1`] = ` -{ - "https://cdn.svgporn.com/logos/react.svg": { - "content": - - React - - - -, - "status": "loaded", - }, -} -`; - -exports[`react-inlinesvg cached requests should skip the cache if \`cacheRequest\` is false 1`] = `{}`; - exports[`react-inlinesvg integration should handle pre-cached entries in the cacheStore 1`] = ` @@ -2461,28 +2429,10 @@ exports[`react-inlinesvg integration should handle pre-cached entries in the cac `; exports[`react-inlinesvg integration should handle race condition with fast src changes: cacheStore 1`] = ` -{ - "http://127.0.0.1:1337/react.svg": { - "content": - - React - - - -, - "status": "loaded", - }, - "https://cdn.svgporn.com/logos/javascript.svg": { - "content": - - React - - - -, - "status": "loaded", - }, -} +[ + "http://127.0.0.1:1337/react.svg", + "https://cdn.svgporn.com/logos/javascript.svg", +] `; exports[`react-inlinesvg integration should handle race condition with fast src changes: svg 1`] = ` diff --git a/test/cache.spec.ts b/test/cache.spec.ts new file mode 100644 index 0000000..504c14a --- /dev/null +++ b/test/cache.spec.ts @@ -0,0 +1,235 @@ +import { waitFor } from '@testing-library/react'; +import CacheMock from 'browser-cache-mock'; +import fetchMock from 'jest-fetch-mock'; + +import CacheStore from '../src/cache'; +import { STATUS } from '../src/config'; + +const cacheMock = new CacheMock(); + +Object.defineProperty(window, 'caches', { + value: { + ...window.caches, + open: async () => + new Promise(resolve => { + setTimeout(() => { + resolve(cacheMock); + }, 500); + }), + ...cacheMock, + }, +}); + +fetchMock.enableMocks(); + +const reactUrl = 'https://cdn.svgporn.com/logos/react.svg'; +const reactContent = 'React'; +const jsUrl = 'https://cdn.svgporn.com/logos/javascript.svg'; +const jsContent = 'JS'; + +describe('CacheStore (internal)', () => { + const cacheStore = new CacheStore(); + + afterEach(async () => { + fetchMock.mockClear(); + await cacheStore.clear(); + }); + + it('should fetch the remote url and add to the cache', async () => { + fetchMock.mockResponseOnce(() => Promise.resolve(reactContent)); + + await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(cacheStore.isCached(reactUrl)).toBeTrue(); + + await cacheStore.clear(); + expect(cacheStore.isCached(reactUrl)).toBeFalse(); + }); + + it('should handle multiple simultaneous requests', async () => { + fetchMock.mockResponse(() => { + return new Promise(resolve => { + setTimeout(() => { + resolve(reactContent); + }, 300); + }); + }); + + expect(cacheStore.get(reactUrl)).toEqual(expect.any(Promise)); + + await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); + await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(cacheStore.isCached(reactUrl)).toBeTrue(); + }); + + it('should handle adding to cache manually', async () => { + cacheStore.set(jsUrl, { content: jsContent, status: STATUS.LOADED }); + + await expect(cacheStore.get(jsUrl)).resolves.toBe(jsContent); + expect(fetchMock).toHaveBeenCalledTimes(0); + + expect(cacheStore.isCached(jsUrl)).toBeTrue(); + expect(cacheStore.keys()).toEqual([jsUrl]); + }); + + it(`should handle stalled entries with ${STATUS.LOADING}`, async () => { + fetchMock.mockResponseOnce(() => Promise.resolve(jsContent)); + + cacheStore.set(jsUrl, { content: jsContent, status: STATUS.LOADING }); + expect(fetchMock).toHaveBeenCalledTimes(0); + + await expect(cacheStore.get(jsUrl)).resolves.toBe(jsContent); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should handle fetch errors', async () => { + fetchMock.mockRejectOnce(new Error('Failed to fetch')); + + await expect(cacheStore.get(jsUrl)).rejects.toThrow('Failed to fetch'); + expect(cacheStore.isCached(jsUrl)).toBeFalse(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should return the cached keys', async () => { + fetchMock.mockResponseOnce(() => Promise.resolve(reactContent)); + + await cacheStore.get(reactUrl); + + expect(cacheStore.keys()).toEqual([reactUrl]); + }); + + it('should return the cached data', async () => { + fetchMock.mockResponseOnce(() => Promise.resolve(reactContent)); + + await cacheStore.get(reactUrl); + + expect(cacheStore.data()).toEqual([ + { + [reactUrl]: { content: reactContent, status: STATUS.LOADED }, + }, + ]); + }); + + it('should delete an item from the cache', async () => { + await cacheStore.get(reactUrl); + expect(cacheStore.keys()).toHaveLength(1); + + await cacheStore.delete(reactUrl); + expect(cacheStore.keys()).toHaveLength(0); + }); + + it('should clear the cache items', async () => { + await cacheStore.get(reactUrl); + expect(cacheStore.keys()).toHaveLength(1); + + await cacheStore.clear(); + expect(cacheStore.keys()).toHaveLength(0); + }); +}); + +describe('CacheStore (external)', () => { + Object.defineProperty(window, 'REACT_INLINESVG_PERSISTENT_CACHE', { + value: true, + }); + const mockReady = jest.fn(); + const cacheStore = new CacheStore(); + + // wait for the cache to be ready + cacheStore.onReady(mockReady); + + beforeEach(() => { + fetchMock.mockResponse(() => Promise.resolve(reactContent)); + }); + + afterEach(async () => { + fetchMock.mockClear(); + await cacheStore.clear(); + }); + + it('should handle initialization', async () => { + await waitFor(() => { + expect(mockReady).toHaveBeenCalledTimes(1); + }); + + cacheStore.onReady(mockReady); + expect(mockReady).toHaveBeenCalledTimes(2); + }); + + it('should fetch the remote url and add to the cache', async () => { + await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); + expect(fetchMock).toHaveBeenCalledTimes(1); + + expect(cacheStore.isCached(reactUrl)).toBeTrue(); + expect(cacheStore.keys()).toEqual([reactUrl]); + }); + + it('should handle multiple simultaneous requests', async () => { + fetchMock.mockResponse(() => { + return new Promise(resolve => { + setTimeout(() => { + resolve(reactContent); + }, 300); + }); + }); + + expect(cacheStore.get(reactUrl)).toEqual(expect.any(Promise)); + + await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should read from the persistent cache if the store is empty', async () => { + // add to the persistent cache directly + await cacheMock.add(reactUrl); + fetchMock.mockClear(); + + await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); + expect(fetchMock).toHaveBeenCalledTimes(0); + }); + + it('should handle delete', async () => { + await cacheStore.get(reactUrl); + + await cacheStore.delete(reactUrl); + + expect(cacheStore.keys()).toHaveLength(0); + await expect(cacheMock.keys()).resolves.toHaveLength(0); + }); + + it('should handle clear', async () => { + fetchMock.mockResponseOnce(() => Promise.resolve(reactContent)); + + await cacheStore.get(reactUrl); + + await cacheStore.clear(); + + expect(cacheStore.keys()).toHaveLength(0); + await expect(cacheMock.keys()).resolves.toHaveLength(0); + }); + + it(`should handle stalled entries with ${STATUS.LOADING}`, async () => { + fetchMock.mockResponseOnce(() => Promise.resolve(jsContent)); + + cacheStore.set(jsUrl, { content: jsContent, status: STATUS.LOADING }); + expect(fetchMock).toHaveBeenCalledTimes(0); + + await expect(cacheStore.get(jsUrl)).resolves.toBe(jsContent); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should handle fetch errors', async () => { + fetchMock.mockRejectOnce(new Error('Failed to fetch')); + + await expect(cacheStore.get(jsUrl)).rejects.toThrow('Failed to fetch'); + expect(cacheStore.isCached(jsUrl)).toBeFalse(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/index.spec.tsx b/test/index.spec.tsx index 2067eb9..f5d5249 100644 --- a/test/index.spec.tsx +++ b/test/index.spec.tsx @@ -1,12 +1,10 @@ import * as React from 'react'; -import { render, waitFor } from '@testing-library/react'; +import { act, render, waitFor as waitForBase } from '@testing-library/react'; import fetchMock from 'jest-fetch-mock'; import ReactInlineSVG, { cacheStore, Props } from '../src/index'; -function Loader() { - return
; -} +jest.useFakeTimers(); const fixtures = { circles: 'http://127.0.0.1:1337/circles.svg', @@ -35,6 +33,21 @@ const fixtures = { const mockOnError = jest.fn(); const mockOnLoad = jest.fn(); +async function waitFor(callback: () => void) { + await waitForBase(callback, { + onTimeout: error => { + console.log('waitFor timeout', error); + + return error; + }, + timeout: 2000, + }); +} + +function Loader() { + return
; +} + function setup({ onLoad, ...rest }: Props) { return render( } onError={mockOnError} onLoad={mockOnLoad} {...rest} />, @@ -49,9 +62,7 @@ describe('react-inlinesvg', () => { afterEach(() => { jest.clearAllMocks(); - Object.keys(cacheStore).forEach(d => { - delete cacheStore[d]; - }); + cacheStore.clear(); }); describe('basic functionality', () => { @@ -396,7 +407,7 @@ describe('react-inlinesvg', () => { fetchMock.disableMocks(); }); - it('should request an SVG only once with cacheRequests prop', async () => { + it('should request an SVG only once', async () => { fetchMock.mockResponseOnce( () => new Promise(resolve => { @@ -414,7 +425,7 @@ describe('react-inlinesvg', () => { setup({ src: fixtures.url }); await waitFor(() => { - expect(mockOnLoad).toHaveBeenNthCalledWith(1, fixtures.url, false); + expect(mockOnLoad).toHaveBeenNthCalledWith(1, fixtures.url, true); }); setup({ src: fixtures.url }); @@ -425,7 +436,30 @@ describe('react-inlinesvg', () => { expect(fetchMock).toHaveBeenNthCalledWith(1, fixtures.url, undefined); - expect(cacheStore).toMatchSnapshot(); + expect(cacheStore.isCached(fixtures.url)).toBeTrue(); + }); + + it('should handle multiple simultaneous instances with the same url', async () => { + fetchMock.mockResponseOnce(() => + Promise.resolve({ + body: 'React', + headers: { 'Content-Type': 'image/svg+xml' }, + }), + ); + + render( + <> + + + + , + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(mockOnLoad).toHaveBeenNthCalledWith(3, fixtures.url, true); + }); }); it('should handle request fail with multiple instances', async () => { @@ -456,20 +490,24 @@ describe('react-inlinesvg', () => { }), ); - cacheStore[fixtures.react] = { + cacheStore.set(fixtures.react, { content: '', status: 'loading', - }; + }); setup({ src: fixtures.react }); + await act(async () => { + jest.runAllTimers(); + }); + await waitFor(() => { - expect(mockOnLoad).toHaveBeenNthCalledWith(1, fixtures.react, false); + expect(mockOnLoad).toHaveBeenNthCalledWith(1, fixtures.react, true); }); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(cacheStore).toMatchSnapshot(); + expect(cacheStore.keys()).toEqual([fixtures.react]); }); it('should handle cached entries with loading status on error', async () => { @@ -477,13 +515,17 @@ describe('react-inlinesvg', () => { fetchMock.mockResponseOnce(() => Promise.reject(error)); - cacheStore[fixtures.react] = { + cacheStore.set(fixtures.react, { content: '', status: 'loading', - }; + }); setup({ src: fixtures.react }); + await act(async () => { + jest.runAllTimers(); + }); + await waitFor(() => { expect(mockOnError).toHaveBeenNthCalledWith(1, error); }); @@ -510,7 +552,7 @@ describe('react-inlinesvg', () => { expect(fetchMock.mock.calls).toHaveLength(1); - expect(cacheStore).toMatchSnapshot(); + expect(cacheStore.keys()).toHaveLength(0); }); }); @@ -556,7 +598,7 @@ describe('react-inlinesvg', () => { }); await waitFor(() => { - expect(mockOnLoad).toHaveBeenNthCalledWith(1, fixtures.react, false); + expect(mockOnLoad).toHaveBeenNthCalledWith(1, fixtures.react, true); }); rerender( @@ -574,7 +616,7 @@ describe('react-inlinesvg', () => { }); await waitFor(() => { - expect(mockOnLoad).toHaveBeenNthCalledWith(2, fixtures.url2, false); + expect(mockOnLoad).toHaveBeenNthCalledWith(2, fixtures.url2, true); }); rerender( @@ -594,7 +636,7 @@ describe('react-inlinesvg', () => { expect(container.querySelector('svg')).toMatchSnapshot('svg'); - expect(cacheStore).toMatchSnapshot('cacheStore'); + expect(cacheStore.keys()).toMatchSnapshot('cacheStore'); fetchMock.disableMocks(); }); @@ -620,10 +662,10 @@ describe('react-inlinesvg', () => { it('should handle pre-cached entries in the cacheStore', async () => { fetchMock.enableMocks(); - cacheStore[fixtures.react] = { + cacheStore.set(fixtures.react, { content: '', status: 'loaded', - }; + }); const { container } = render(); @@ -692,7 +734,7 @@ describe('react-inlinesvg', () => { }); it('should trigger an error if the request content-type is not valid', async () => { - await setup({ src: fixtures.react_png }); + setup({ src: fixtures.react_png }); await waitFor(() => { expect(mockOnError).toHaveBeenCalledWith(new Error("Content type isn't valid: image/png")); @@ -700,7 +742,7 @@ describe('react-inlinesvg', () => { }); it('should trigger an error if the content is not valid', async () => { - await setup({ src: fixtures.html }); + setup({ src: fixtures.html }); await waitFor(() => { expect(mockOnError).toHaveBeenCalledWith( diff --git a/test/indexWithPersistentCache.spec.tsx b/test/indexWithPersistentCache.spec.tsx new file mode 100644 index 0000000..43ec8fb --- /dev/null +++ b/test/indexWithPersistentCache.spec.tsx @@ -0,0 +1,94 @@ +/* eslint-disable import/first */ +import * as React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import CacheMock from 'browser-cache-mock'; +import fetchMock from 'jest-fetch-mock'; + +const cacheMock = new CacheMock(); + +Object.defineProperty(window, 'caches', { + value: { + ...window.caches, + open: async () => + new Promise(resolve => { + setTimeout(() => { + resolve(cacheMock); + }, 500); + }), + ...cacheMock, + }, +}); + +import ReactInlineSVG, { cacheStore, Props } from '../src/index'; +import CacheProvider from '../src/provider'; + +function Loader() { + return
; +} + +const mockOnError = jest.fn(); +const mockOnLoad = jest.fn(); + +const url = 'https://cdn.svgporn.com/logos/react.svg'; + +fetchMock.enableMocks(); + +fetchMock.mockResponse(() => + Promise.resolve({ + body: 'React', + headers: { 'Content-Type': 'image/svg+xml' }, + }), +); + +function setup({ onLoad, ...rest }: Props) { + return render( + } onError={mockOnError} onLoad={mockOnLoad} {...rest} />, + { wrapper: ({ children }) => {children} }, + ); +} + +describe('react-inlinesvg (with persistent cache)', () => { + afterEach(async () => { + fetchMock.mockClear(); + await cacheStore.clear(); + }); + + it('should request an SVG only once', async () => { + setup({ src: url }); + + await waitFor(() => { + expect(mockOnLoad).toHaveBeenNthCalledWith(1, url, true); + }); + + setup({ src: url }); + + await waitFor(() => { + expect(mockOnLoad).toHaveBeenNthCalledWith(2, url, true); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(cacheStore.isCached(url)).toBeTrue(); + }); + + it('should handle multiple simultaneous instances with the same url', async () => { + render( + <> + + + + , + ); + + await waitFor(() => { + expect(mockOnLoad).toHaveBeenNthCalledWith(3, url, true); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + setup({ src: url }); + + await waitFor(() => { + expect(mockOnLoad).toHaveBeenNthCalledWith(4, url, true); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unsupported.spec.tsx b/test/unsupported.spec.tsx index 25c24da..1ecade9 100644 --- a/test/unsupported.spec.tsx +++ b/test/unsupported.spec.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import InlineSVG, { Props } from '../src'; @@ -50,19 +50,22 @@ describe('unsupported environments', () => { mockCanUseDOM = true; mockIsSupportedEnvironment = true; - const { container } = await setup(); + const { container } = setup(); + + await waitFor(() => { + expect(mockOnError).toHaveBeenCalledWith(new Error('fetch is not a function')); + }); - expect(mockOnError).toHaveBeenCalledWith(new Error('fetch is not a function')); expect(container.firstChild).toMatchSnapshot(); window.fetch = globalFetch; }); - it("shouldn't not render anything if is an unsupported browser", async () => { + it("shouldn't not render anything if is an unsupported browser", () => { mockCanUseDOM = true; mockIsSupportedEnvironment = false; - const { container } = await setup(); + const { container } = setup(); expect(mockOnError).toHaveBeenCalledWith(new Error('Browser does not support SVG')); expect(container.firstChild).toMatchSnapshot(); diff --git a/tsconfig.json b/tsconfig.json index a520e6b..423c53f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,8 +2,7 @@ "extends": "@gilbarbara/tsconfig", "compilerOptions": { "downlevelIteration": true, - "lib": ["dom", "dom.iterable", "esnext"], - "outDir": "./lib", + "noEmit": true, "target": "es5" }, "include": ["src/**/*"]