diff --git a/README.md b/README.md index c1ecacf..1e66b6b 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,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 a `isCached` boolean **preProcessor** {function} ▶︎ `string` A function to process the contents of the SVG text before parsing. @@ -126,7 +126,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" diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..a7ec042 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,77 @@ +import { CACHE_MAX_RETRIES, STATUS } from './config'; +import { request, sleep } from './helpers'; +import { StorageItem } from './types'; + +export class CacheStore { + private readonly cacheStore: Map; + + constructor() { + this.cacheStore = new Map(); + } + + public async get(url: string, fetchOptions?: RequestInit) { + await 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) { + 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) { + 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; + } + } + } + + public keys(): Array { + return [...this.cacheStore.keys()]; + } + + public delete(url: string) { + this.cacheStore.delete(url); + } + + public clear() { + this.cacheStore.clear(); + } +} + +export default new CacheStore(); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..0c32644 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,10 @@ +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..b5df742 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,10 +1,10 @@ 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'; - -export const cacheStore: { [key: string]: StorageItem } = Object.create(null); +import cacheStore from './cache'; +import { STATUS } from './config'; +import { canUseDOM, isSupportedEnvironment, omit, randomString, request } from './helpers'; +import { FetchError, Props, State, Status } from './types'; export default class InlineSVG extends React.PureComponent { private readonly hash: string; @@ -22,7 +22,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 +66,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 +91,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 +186,7 @@ export default class InlineSVG extends React.PureComponent { this.setState( { content, - hasCache, + isCached: hasCache, status: STATUS.LOADED, }, this.getElement, @@ -193,18 +201,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 +222,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 +249,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']; @@ -378,3 +330,5 @@ export default class InlineSVG extends React.PureComponent { } export * from './types'; + +export { default as cacheStore } from './cache'; diff --git a/src/types.ts b/src/types.ts index d573cb2..ecd8524 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; @@ -22,12 +22,13 @@ export interface Props extends Omit, 'onLoad' | 'onEr title?: string | null; uniqueHash?: string; uniquifyIDs?: boolean; + usePersistentCache?: boolean; } 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..58e3124 100644 --- a/test/__snapshots__/index.spec.tsx.snap +++ b/test/__snapshots__/index.spec.tsx.snap @@ -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..6eb9086 --- /dev/null +++ b/test/cache.spec.ts @@ -0,0 +1,98 @@ +import fetchMock from 'jest-fetch-mock'; + +import { CacheStore } from '../src/cache'; +import { STATUS } from '../src/config'; + +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(() => { + fetchMock.mockClear(); + cacheStore.clear(); + }); + + it('should create a cache store', async () => { + expect(cacheStore).toBeInstanceOf(CacheStore); + }); + + 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.keys()).toEqual([reactUrl]); + + cacheStore.clear(); + expect(cacheStore.keys()).toHaveLength(0); + }); + + 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); + }); + + it('should add 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.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 handle delete', async () => { + await cacheStore.get(reactUrl); + + cacheStore.delete(reactUrl); + + expect(cacheStore.keys()).toHaveLength(0); + }); + + it('should handle clear', async () => { + await cacheStore.get(reactUrl); + + cacheStore.clear(); + + expect(cacheStore.keys()).toHaveLength(0); + }); +}); diff --git a/test/index.spec.tsx b/test/index.spec.tsx index 2067eb9..8aeaafb 100644 --- a/test/index.spec.tsx +++ b/test/index.spec.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { render, waitFor } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import fetchMock from 'jest-fetch-mock'; import ReactInlineSVG, { cacheStore, Props } from '../src/index'; @@ -32,6 +32,8 @@ const fixtures = { ' ', } as const; +jest.useFakeTimers(); + const mockOnError = jest.fn(); const mockOnLoad = jest.fn(); @@ -49,9 +51,7 @@ describe('react-inlinesvg', () => { afterEach(() => { jest.clearAllMocks(); - Object.keys(cacheStore).forEach(d => { - delete cacheStore[d]; - }); + cacheStore.clear(); }); describe('basic functionality', () => { @@ -396,7 +396,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 +414,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 +425,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 +479,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 +504,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 +541,7 @@ describe('react-inlinesvg', () => { expect(fetchMock.mock.calls).toHaveLength(1); - expect(cacheStore).toMatchSnapshot(); + expect(cacheStore.keys()).toHaveLength(0); }); }); @@ -556,7 +587,7 @@ describe('react-inlinesvg', () => { }); await waitFor(() => { - expect(mockOnLoad).toHaveBeenNthCalledWith(1, fixtures.react, false); + expect(mockOnLoad).toHaveBeenNthCalledWith(1, fixtures.react, true); }); rerender( @@ -574,7 +605,7 @@ describe('react-inlinesvg', () => { }); await waitFor(() => { - expect(mockOnLoad).toHaveBeenNthCalledWith(2, fixtures.url2, false); + expect(mockOnLoad).toHaveBeenNthCalledWith(2, fixtures.url2, true); }); rerender( @@ -594,7 +625,7 @@ describe('react-inlinesvg', () => { expect(container.querySelector('svg')).toMatchSnapshot('svg'); - expect(cacheStore).toMatchSnapshot('cacheStore'); + expect(cacheStore.keys()).toMatchSnapshot('cacheStore'); fetchMock.disableMocks(); }); @@ -620,10 +651,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(); 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();