react-inlinesvg 4.1.8 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,12 @@
1
1
  import { CACHE_MAX_RETRIES, CACHE_NAME, STATUS } from '../config';
2
2
  import { StorageItem } from '../types';
3
3
 
4
- import { canUseDOM, request, sleep } from './helpers';
4
+ import { canUseDOM, request } from './helpers';
5
+
6
+ export interface CacheStoreOptions {
7
+ name?: string;
8
+ persistent?: boolean;
9
+ }
5
10
 
6
11
  export default class CacheStore {
7
12
  private cacheApi: Cache | undefined;
@@ -9,49 +14,82 @@ export default class CacheStore {
9
14
  private readonly subscribers: Array<() => void> = [];
10
15
  public isReady = false;
11
16
 
12
- constructor() {
13
- this.cacheStore = new Map<string, StorageItem>();
17
+ constructor(options: CacheStoreOptions = {}) {
18
+ const { name = CACHE_NAME, persistent = false } = options;
14
19
 
15
- let cacheName = CACHE_NAME;
16
- let usePersistentCache = false;
20
+ this.cacheStore = new Map<string, StorageItem>();
17
21
 
18
- if (canUseDOM()) {
19
- cacheName = window.REACT_INLINESVG_CACHE_NAME ?? CACHE_NAME;
20
- usePersistentCache = !!window.REACT_INLINESVG_PERSISTENT_CACHE && 'caches' in window;
21
- }
22
+ const usePersistentCache = persistent && canUseDOM() && 'caches' in window;
22
23
 
23
24
  if (usePersistentCache) {
25
+ // eslint-disable-next-line promise/catch-or-return
24
26
  caches
25
- .open(cacheName)
27
+ .open(name)
26
28
  .then(cache => {
27
29
  this.cacheApi = cache;
28
30
  })
29
31
  .catch(error => {
30
32
  // eslint-disable-next-line no-console
31
33
  console.error(`Failed to open cache: ${error.message}`);
34
+ this.cacheApi = undefined;
32
35
  })
33
36
  .finally(() => {
34
37
  this.isReady = true;
35
- this.subscribers.forEach(callback => callback());
38
+ // Copy to avoid mutation issues
39
+ const callbacks = [...this.subscribers];
40
+
41
+ // Clear array efficiently
42
+ this.subscribers.length = 0;
43
+
44
+ callbacks.forEach(callback => {
45
+ try {
46
+ callback();
47
+ } catch (error: any) {
48
+ // eslint-disable-next-line no-console
49
+ console.error(`Error in CacheStore subscriber callback: ${error.message}`);
50
+ }
51
+ });
36
52
  });
37
53
  } else {
38
54
  this.isReady = true;
39
55
  }
40
56
  }
41
57
 
42
- public onReady(callback: () => void) {
58
+ public onReady(callback: () => void): () => void {
43
59
  if (this.isReady) {
44
60
  callback();
45
- } else {
46
- this.subscribers.push(callback);
61
+
62
+ return () => {};
47
63
  }
64
+
65
+ this.subscribers.push(callback);
66
+
67
+ return () => {
68
+ const index = this.subscribers.indexOf(callback);
69
+
70
+ if (index >= 0) {
71
+ this.subscribers.splice(index, 1);
72
+ }
73
+ };
74
+ }
75
+
76
+ private waitForReady(): Promise<void> {
77
+ if (this.isReady) {
78
+ return Promise.resolve();
79
+ }
80
+
81
+ return new Promise(resolve => {
82
+ this.onReady(resolve);
83
+ });
48
84
  }
49
85
 
50
86
  public async get(url: string, fetchOptions?: RequestInit) {
51
- await (this.cacheApi
52
- ? this.fetchAndAddToPersistentCache(url, fetchOptions)
53
- : this.fetchAndAddToInternalCache(url, fetchOptions));
87
+ await this.fetchAndCache(url, fetchOptions);
88
+
89
+ return this.cacheStore.get(url)?.content ?? '';
90
+ }
54
91
 
92
+ public getContent(url: string): string {
55
93
  return this.cacheStore.get(url)?.content ?? '';
56
94
  }
57
95
 
@@ -63,33 +101,11 @@ export default class CacheStore {
63
101
  return this.cacheStore.get(url)?.status === STATUS.LOADED;
64
102
  }
65
103
 
66
- private async fetchAndAddToInternalCache(url: string, fetchOptions?: RequestInit) {
67
- const cache = this.cacheStore.get(url);
68
-
69
- if (cache?.status === STATUS.LOADING) {
70
- await this.handleLoading(url, async () => {
71
- this.cacheStore.set(url, { content: '', status: STATUS.IDLE });
72
- await this.fetchAndAddToInternalCache(url, fetchOptions);
73
- });
74
-
75
- return;
76
- }
77
-
78
- if (!cache?.content) {
79
- this.cacheStore.set(url, { content: '', status: STATUS.LOADING });
80
-
81
- try {
82
- const content = await request(url, fetchOptions);
83
-
84
- this.cacheStore.set(url, { content, status: STATUS.LOADED });
85
- } catch (error: any) {
86
- this.cacheStore.set(url, { content: '', status: STATUS.FAILED });
87
- throw error;
88
- }
104
+ private async fetchAndCache(url: string, fetchOptions?: RequestInit) {
105
+ if (!this.isReady) {
106
+ await this.waitForReady();
89
107
  }
90
- }
91
108
 
92
- private async fetchAndAddToPersistentCache(url: string, fetchOptions?: RequestInit) {
93
109
  const cache = this.cacheStore.get(url);
94
110
 
95
111
  if (cache?.status === STATUS.LOADED) {
@@ -97,9 +113,9 @@ export default class CacheStore {
97
113
  }
98
114
 
99
115
  if (cache?.status === STATUS.LOADING) {
100
- await this.handleLoading(url, async () => {
116
+ await this.handleLoading(url, fetchOptions?.signal || undefined, async () => {
101
117
  this.cacheStore.set(url, { content: '', status: STATUS.IDLE });
102
- await this.fetchAndAddToPersistentCache(url, fetchOptions);
118
+ await this.fetchAndCache(url, fetchOptions);
103
119
  });
104
120
 
105
121
  return;
@@ -107,21 +123,10 @@ export default class CacheStore {
107
123
 
108
124
  this.cacheStore.set(url, { content: '', status: STATUS.LOADING });
109
125
 
110
- const data = await this.cacheApi?.match(url);
111
-
112
- if (data) {
113
- const content = await data.text();
114
-
115
- this.cacheStore.set(url, { content, status: STATUS.LOADED });
116
-
117
- return;
118
- }
119
-
120
126
  try {
121
- await this.cacheApi?.add(new Request(url, fetchOptions));
122
-
123
- const response = await this.cacheApi?.match(url);
124
- const content = (await response?.text()) ?? '';
127
+ const content = this.cacheApi
128
+ ? await this.fetchFromPersistentCache(url, fetchOptions)
129
+ : await request(url, fetchOptions);
125
130
 
126
131
  this.cacheStore.set(url, { content, status: STATUS.LOADED });
127
132
  } catch (error: any) {
@@ -130,18 +135,40 @@ export default class CacheStore {
130
135
  }
131
136
  }
132
137
 
133
- private async handleLoading(url: string, callback: () => Promise<void>) {
134
- let retryCount = 0;
138
+ private async fetchFromPersistentCache(url: string, fetchOptions?: RequestInit): Promise<string> {
139
+ const data = await this.cacheApi?.match(url);
135
140
 
136
- while (this.cacheStore.get(url)?.status === STATUS.LOADING && retryCount < CACHE_MAX_RETRIES) {
137
- // eslint-disable-next-line no-await-in-loop
138
- await sleep(0.1);
139
- retryCount += 1;
141
+ if (data) {
142
+ return data.text();
140
143
  }
141
144
 
142
- if (retryCount >= CACHE_MAX_RETRIES) {
143
- await callback();
145
+ await this.cacheApi?.add(new Request(url, fetchOptions));
146
+
147
+ const response = await this.cacheApi?.match(url);
148
+
149
+ return (await response?.text()) ?? '';
150
+ }
151
+
152
+ private async handleLoading(
153
+ url: string,
154
+ signal: AbortSignal | undefined,
155
+ callback: () => Promise<void>,
156
+ ) {
157
+ for (let retryCount = 0; retryCount < CACHE_MAX_RETRIES; retryCount++) {
158
+ if (signal?.aborted) {
159
+ throw signal.reason instanceof Error
160
+ ? signal.reason
161
+ : new DOMException('The operation was aborted.', 'AbortError');
162
+ }
163
+
164
+ if (this.cacheStore.get(url)?.status !== STATUS.LOADING) {
165
+ return;
166
+ }
167
+
168
+ await sleep(0.1);
144
169
  }
170
+
171
+ await callback();
145
172
  }
146
173
 
147
174
  public keys(): Array<string> {
@@ -164,12 +191,15 @@ export default class CacheStore {
164
191
  if (this.cacheApi) {
165
192
  const keys = await this.cacheApi.keys();
166
193
 
167
- for (const key of keys) {
168
- // eslint-disable-next-line no-await-in-loop
169
- await this.cacheApi.delete(key);
170
- }
194
+ await Promise.allSettled(keys.map(key => this.cacheApi!.delete(key)));
171
195
  }
172
196
 
173
197
  this.cacheStore.clear();
174
198
  }
175
199
  }
200
+
201
+ function sleep(seconds = 1) {
202
+ return new Promise(resolve => {
203
+ setTimeout(resolve, seconds * 1000);
204
+ });
205
+ }
@@ -1,5 +1,3 @@
1
- import type { PlainObject } from '../types';
2
-
3
1
  function randomCharacter(character: string) {
4
2
  return character[Math.floor(Math.random() * character.length)];
5
3
  }
@@ -15,7 +13,7 @@ export function isSupportedEnvironment(): boolean {
15
13
  /**
16
14
  * Remove properties from an object
17
15
  */
18
- export function omit<T extends PlainObject, K extends keyof T>(
16
+ export function omit<T extends Record<string, unknown>, K extends keyof T>(
19
17
  input: T,
20
18
  ...filter: K[]
21
19
  ): Omit<T, K> {
@@ -62,12 +60,6 @@ export async function request(url: string, options?: RequestInit) {
62
60
  return response.text();
63
61
  }
64
62
 
65
- export function sleep(seconds = 1) {
66
- return new Promise(resolve => {
67
- setTimeout(resolve, seconds * 1000);
68
- });
69
- }
70
-
71
63
  export function supportsInlineSVG(): boolean {
72
64
  /* c8 ignore next 3 */
73
65
  if (!document) {
@@ -1,4 +1,9 @@
1
- import { useEffect, useRef } from 'react';
1
+ import { EffectCallback, useEffect, useRef } from 'react';
2
+
3
+ export function useMount(effect: EffectCallback) {
4
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5
+ useEffect(effect, []);
6
+ }
2
7
 
3
8
  export function usePrevious<T>(state: T): T | undefined {
4
9
  const ref = useRef<T>(undefined);
@@ -0,0 +1,272 @@
1
+ import { isValidElement, useCallback, useEffect, useReducer, useRef } from 'react';
2
+ import convert from 'react-from-dom';
3
+
4
+ import { STATUS } from '../config';
5
+ import type { FetchError, Props, State } from '../types';
6
+
7
+ import type CacheStore from './cache';
8
+ import { canUseDOM, isSupportedEnvironment, randomString, request } from './helpers';
9
+ import { useMount, usePrevious } from './hooks';
10
+ import { getNode } from './utils';
11
+
12
+ export default function useInlineSVG(props: Props, cacheStore: CacheStore) {
13
+ const {
14
+ baseURL,
15
+ cacheRequests = true,
16
+ description,
17
+ fetchOptions,
18
+ onError,
19
+ onLoad,
20
+ preProcessor,
21
+ src,
22
+ title,
23
+ uniqueHash,
24
+ uniquifyIDs,
25
+ } = props;
26
+
27
+ const hash = useRef(uniqueHash ?? randomString(8));
28
+ const fetchOptionsRef = useRef(fetchOptions);
29
+ const onErrorRef = useRef(onError);
30
+ const onLoadRef = useRef(onLoad);
31
+ const preProcessorRef = useRef(preProcessor);
32
+
33
+ fetchOptionsRef.current = fetchOptions;
34
+ onErrorRef.current = onError;
35
+ onLoadRef.current = onLoad;
36
+ preProcessorRef.current = preProcessor;
37
+
38
+ const [state, setState] = useReducer(
39
+ (previousState: State, nextState: Partial<State>) => ({
40
+ ...previousState,
41
+ ...nextState,
42
+ }),
43
+ {
44
+ content: '',
45
+ element: null,
46
+ isCached: false,
47
+ status: STATUS.IDLE,
48
+ },
49
+ (initial): State => {
50
+ const cached = cacheRequests && cacheStore.isCached(src);
51
+
52
+ if (!cached) {
53
+ return initial;
54
+ }
55
+
56
+ const cachedContent = cacheStore.getContent(src);
57
+
58
+ try {
59
+ const node = getNode({
60
+ ...props,
61
+ handleError: () => {},
62
+ hash: hash.current,
63
+ content: cachedContent,
64
+ });
65
+
66
+ if (!node) {
67
+ return { ...initial, content: cachedContent, isCached: true, status: STATUS.LOADED };
68
+ }
69
+
70
+ const convertedElement = convert(node as Node);
71
+
72
+ if (convertedElement && isValidElement(convertedElement)) {
73
+ return {
74
+ content: cachedContent,
75
+ element: convertedElement,
76
+ isCached: true,
77
+ status: STATUS.READY,
78
+ };
79
+ }
80
+ } catch {
81
+ // Fall through to effect-driven flow
82
+ }
83
+
84
+ return {
85
+ ...initial,
86
+ content: cachedContent,
87
+ isCached: true,
88
+ status: STATUS.LOADED,
89
+ };
90
+ },
91
+ );
92
+ const { content, element, isCached, status } = state;
93
+ const previousProps = usePrevious(props);
94
+ const previousState = usePrevious(state);
95
+ const isActive = useRef(false);
96
+ const isInitialized = useRef(false);
97
+
98
+ const handleError = useCallback((error: Error | FetchError) => {
99
+ if (isActive.current) {
100
+ setState({
101
+ status:
102
+ error.message === 'Browser does not support SVG' ? STATUS.UNSUPPORTED : STATUS.FAILED,
103
+ });
104
+
105
+ onErrorRef.current?.(error);
106
+ }
107
+ }, []);
108
+
109
+ const getElement = useCallback(() => {
110
+ try {
111
+ const node = getNode({
112
+ baseURL,
113
+ content,
114
+ description,
115
+ handleError,
116
+ hash: hash.current,
117
+ preProcessor: preProcessorRef.current,
118
+ src,
119
+ title,
120
+ uniquifyIDs,
121
+ }) as Node;
122
+ const convertedElement = convert(node);
123
+
124
+ if (!convertedElement || !isValidElement(convertedElement)) {
125
+ throw new Error('Could not convert the src to a React element');
126
+ }
127
+
128
+ setState({
129
+ element: convertedElement,
130
+ status: STATUS.READY,
131
+ });
132
+ } catch (error: any) {
133
+ handleError(error);
134
+ }
135
+ }, [baseURL, content, description, handleError, src, title, uniquifyIDs]);
136
+
137
+ // Mount
138
+ useMount(() => {
139
+ isActive.current = true;
140
+
141
+ if (!canUseDOM() || isInitialized.current) {
142
+ return undefined;
143
+ }
144
+
145
+ try {
146
+ if (status === STATUS.READY) {
147
+ onLoadRef.current?.(src, isCached);
148
+ } else if (status === STATUS.IDLE) {
149
+ if (!isSupportedEnvironment()) {
150
+ throw new Error('Browser does not support SVG');
151
+ }
152
+
153
+ if (!src) {
154
+ throw new Error('Missing src');
155
+ }
156
+
157
+ setState({ content: '', element: null, isCached: false, status: STATUS.LOADING });
158
+ }
159
+ } catch (error: any) {
160
+ handleError(error);
161
+ }
162
+
163
+ isInitialized.current = true;
164
+
165
+ return () => {
166
+ isActive.current = false;
167
+ };
168
+ });
169
+
170
+ // Src changes
171
+ useEffect(() => {
172
+ if (!canUseDOM() || !previousProps) {
173
+ return;
174
+ }
175
+
176
+ if (previousProps.src !== src) {
177
+ if (!src) {
178
+ handleError(new Error('Missing src'));
179
+
180
+ return;
181
+ }
182
+
183
+ setState({ content: '', element: null, isCached: false, status: STATUS.LOADING });
184
+ }
185
+ }, [handleError, previousProps, src]);
186
+
187
+ // Fetch content when status is LOADING
188
+ useEffect(() => {
189
+ if (status !== STATUS.LOADING) {
190
+ return undefined;
191
+ }
192
+
193
+ const controller = new AbortController();
194
+ let active = true;
195
+
196
+ (async () => {
197
+ try {
198
+ const dataURI = /^data:image\/svg[^,]*?(;base64)?,(.*)/.exec(src);
199
+ let inlineSrc;
200
+
201
+ if (dataURI) {
202
+ inlineSrc = dataURI[1] ? window.atob(dataURI[2]) : decodeURIComponent(dataURI[2]);
203
+ } else if (src.includes('<svg')) {
204
+ inlineSrc = src;
205
+ }
206
+
207
+ if (inlineSrc) {
208
+ if (active) {
209
+ setState({ content: inlineSrc, isCached: false, status: STATUS.LOADED });
210
+ }
211
+
212
+ return;
213
+ }
214
+
215
+ const fetchParameters = { ...fetchOptionsRef.current, signal: controller.signal };
216
+ let loadedContent: string;
217
+ let hasCache = false;
218
+
219
+ if (cacheRequests) {
220
+ hasCache = cacheStore.isCached(src);
221
+ loadedContent = await cacheStore.get(src, fetchParameters);
222
+ } else {
223
+ loadedContent = await request(src, fetchParameters);
224
+ }
225
+
226
+ if (active) {
227
+ setState({ content: loadedContent, isCached: hasCache, status: STATUS.LOADED });
228
+ }
229
+ } catch (error: any) {
230
+ if (active && error.name !== 'AbortError') {
231
+ handleError(error);
232
+ }
233
+ }
234
+ })();
235
+
236
+ return () => {
237
+ active = false;
238
+ controller.abort();
239
+ };
240
+ }, [cacheRequests, cacheStore, handleError, src, status]);
241
+
242
+ // LOADED -> READY
243
+ useEffect(() => {
244
+ if (status === STATUS.LOADED && content) {
245
+ getElement();
246
+ }
247
+ }, [content, getElement, status]);
248
+
249
+ // Title and description changes
250
+ useEffect(() => {
251
+ if (!canUseDOM() || !previousProps || previousProps.src !== src) {
252
+ return;
253
+ }
254
+
255
+ if (previousProps.title !== title || previousProps.description !== description) {
256
+ getElement();
257
+ }
258
+ }, [description, getElement, previousProps, src, title]);
259
+
260
+ // READY -> onLoad
261
+ useEffect(() => {
262
+ if (!previousState) {
263
+ return;
264
+ }
265
+
266
+ if (status === STATUS.READY && previousState.status !== STATUS.READY) {
267
+ onLoadRef.current?.(src, isCached);
268
+ }
269
+ }, [isCached, previousState, src, status]);
270
+
271
+ return { element, status };
272
+ }
@@ -11,6 +11,36 @@ interface UpdateSVGAttributesOptions extends Pick<Props, 'baseURL' | 'uniquifyID
11
11
  hash: string;
12
12
  }
13
13
 
14
+ function uniquifyStyleIds(svgText: string, hash: string, baseURL: string): string {
15
+ const idMatches = svgText.matchAll(/\bid=(["'])([^"']+)\1/g);
16
+ const ids = [...new Set([...idMatches].map(m => m[2]))];
17
+
18
+ if (!ids.length) {
19
+ return svgText;
20
+ }
21
+
22
+ ids.sort((a, b) => b.length - a.length);
23
+
24
+ return svgText.replace(/<style[^>]*>([\S\s]*?)<\/style>/gi, (fullMatch, cssContent) => {
25
+ let modified = cssContent as string;
26
+
27
+ for (const id of ids) {
28
+ const escaped = id.replace(/[$()*+.?[\\\]^{|}]/g, '\\$&');
29
+
30
+ modified = modified.replace(
31
+ new RegExp(`url\\((['"]?)#${escaped}\\1\\)`, 'g'),
32
+ `url($1${baseURL}#${id}__${hash}$1)`,
33
+ );
34
+ modified = modified.replace(
35
+ new RegExp(`#${escaped}(?![a-zA-Z0-9_-])`, 'g'),
36
+ `#${id}__${hash}`,
37
+ );
38
+ }
39
+
40
+ return fullMatch.replace(cssContent, modified);
41
+ });
42
+ }
43
+
14
44
  export function getNode(options: GetNodeOptions) {
15
45
  const {
16
46
  baseURL,
@@ -24,7 +54,12 @@ export function getNode(options: GetNodeOptions) {
24
54
  } = options;
25
55
 
26
56
  try {
27
- const svgText = processSVG(content, preProcessor);
57
+ let svgText = processSVG(content, preProcessor);
58
+
59
+ if (uniquifyIDs) {
60
+ svgText = uniquifyStyleIds(svgText, hash, baseURL ?? '');
61
+ }
62
+
28
63
  const node = convert(svgText, { nodeOnly: true });
29
64
 
30
65
  if (!node || !(node instanceof SVGSVGElement)) {
package/src/provider.tsx CHANGED
@@ -1,6 +1,8 @@
1
- import { ReactNode } from 'react';
1
+ import React, { createContext, ReactNode, useContext, useState } from 'react';
2
2
 
3
- import { canUseDOM } from './modules/helpers';
3
+ import CacheStore from './modules/cache';
4
+
5
+ const CacheContext = createContext<CacheStore | null>(null);
4
6
 
5
7
  interface Props {
6
8
  children: ReactNode;
@@ -8,10 +10,11 @@ interface Props {
8
10
  }
9
11
 
10
12
  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
- }
13
+ const [store] = useState(() => new CacheStore({ name, persistent: true }));
14
+
15
+ return <CacheContext.Provider value={store}>{children}</CacheContext.Provider>;
16
+ }
15
17
 
16
- return children;
18
+ export function useCacheStore(): CacheStore | null {
19
+ return useContext(CacheContext);
17
20
  }