Skip to content

Commit 943a876

Browse files
committed
fix: prevent initialization on client to duplicate symbols
BREAKING: Support for internet explorer is dropped, could still be compatible but it is not tested
1 parent 63687d1 commit 943a876

File tree

10 files changed

+1963
-2541
lines changed

10 files changed

+1963
-2541
lines changed

package.json

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-lazy-svg",
3-
"version": "2.0.2",
3+
"version": "3.0.0",
44
"license": "MIT",
55
"keywords": [
66
"react",
@@ -11,7 +11,8 @@
1111
"icons",
1212
"ssr",
1313
"typescript",
14-
"react-component"
14+
"react-component",
15+
"critical-svg"
1516
],
1617
"repository": {
1718
"url": "https://github.com/kaoDev/react-lazy-svg"
@@ -20,7 +21,7 @@
2021
"name": "Kalle Ott",
2122
"url": "https://github.com/kaoDev/"
2223
},
23-
"description": "react-lazy-svg is a simple way to use SVGs with the performance benefits of a sprite-sheet and svg css styling possibilities. Without bloating the bundle. It automatically creates a sprite-sheet for all used SVGs on the client but also provides a function to create a server side rendered sprite-sheet for icons used in the first paint.",
24+
"description": "react-lazy-svg is a simple way to use SVGs with the performance benefits of a sprite-sheet and svg css styling possibilities. Without bloating the bundle. It automatically creates a sprite-sheet for all used SVGs on the client but also provides the option to inject the critical SVGs for the server side rendered html.",
2425
"main": "dist/index.cjs.js",
2526
"module": "dist/index.esm.js",
2627
"types": "dist/index.d.ts",
@@ -54,32 +55,33 @@
5455
"proseWrap": "always"
5556
},
5657
"devDependencies": {
57-
"@babel/preset-typescript": "^7.13.0",
58-
"@rollup/plugin-babel": "^5.3.0",
59-
"@rollup/plugin-multi-entry": "^4.0.0",
60-
"@testing-library/jest-dom": "^5.11.9",
61-
"@testing-library/react": "^11.2.5",
62-
"@types/jest": "^26.0.20",
63-
"@types/react": "^17.0.3",
64-
"@types/react-dom": "^17.0.1",
65-
"@typescript-eslint/eslint-plugin": "^4.16.1",
66-
"@typescript-eslint/parser": "^4.16.1",
67-
"eslint": "^7.21.0",
68-
"eslint-config-prettier": "^8.1.0",
69-
"eslint-plugin-react": "^7.22.0",
70-
"eslint-plugin-react-hooks": "^4.2.0",
71-
"husky": "^5.1.3",
72-
"jest": "^26.6.3",
73-
"prettier": "^2.2.1",
74-
"react": "^17.0.1",
75-
"react-dom": "^17.0.1",
76-
"rollup": "^2.40.0",
77-
"rollup-plugin-filesize": "^9.1.1",
78-
"rollup-plugin-typescript2": "^0.30.0",
79-
"ts-jest": "^26.5.3",
80-
"tslib": "^2.1.0",
81-
"typescript": "^4.2.3",
82-
"w3c-xmlserializer": "^2.0.0"
58+
"@babel/preset-typescript": "^7.18.6",
59+
"@rollup/plugin-babel": "^5.3.1",
60+
"@rollup/plugin-multi-entry": "^4.1.0",
61+
"@testing-library/jest-dom": "^5.16.4",
62+
"@testing-library/react": "^13.3.0",
63+
"@types/jest": "^28.1.4",
64+
"@types/react": "^18.0.15",
65+
"@types/react-dom": "^18.0.6",
66+
"@typescript-eslint/eslint-plugin": "^5.30.5",
67+
"@typescript-eslint/parser": "^5.30.5",
68+
"eslint": "^8.19.0",
69+
"eslint-config-prettier": "^8.5.0",
70+
"eslint-plugin-react": "^7.30.1",
71+
"eslint-plugin-react-hooks": "^4.6.0",
72+
"husky": "^8.0.1",
73+
"jest": "^28.1.2",
74+
"jest-environment-jsdom": "^28.1.2",
75+
"prettier": "^2.7.1",
76+
"react": "^18.2.0",
77+
"react-dom": "^18.2.0",
78+
"rollup": "^2.75.7",
79+
"rollup-plugin-filesize": "^9.1.2",
80+
"rollup-plugin-typescript2": "^0.32.1",
81+
"ts-jest": "^28.0.5",
82+
"tslib": "^2.4.0",
83+
"typescript": "^4.7.4",
84+
"w3c-xmlserializer": "^3.0.0"
8385
},
8486
"dependencies": {}
8587
}

src/SpriteSheet.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React, { useRef } from 'react';
2+
import { createPortal } from 'react-dom';
3+
import { defaultInternalSpriteSheetId, isSSR } from './constants';
4+
import { IconData } from './types';
5+
6+
const hidden = {
7+
height: 0,
8+
width: 0,
9+
position: 'absolute',
10+
visibility: 'hidden',
11+
} as const;
12+
13+
export function SpriteSheet({
14+
icons,
15+
spriteSheetId = defaultInternalSpriteSheetId,
16+
embeddedSSR,
17+
}: {
18+
icons: IconData[];
19+
spriteSheetId?: string;
20+
embeddedSSR?: boolean;
21+
}) {
22+
const spriteSheetContainer = useRef(
23+
!isSSR && !embeddedSSR ? document.getElementById(spriteSheetId) : null,
24+
);
25+
26+
const renderedIcons = icons.map(
27+
({
28+
id,
29+
svgString,
30+
attributes: { width, height, ['xmlns:xlink']: xmlnsXlink, ...attributes },
31+
}) => {
32+
return (
33+
<symbol
34+
key={id}
35+
id={id}
36+
xmlnsXlink={xmlnsXlink}
37+
{...attributes}
38+
dangerouslySetInnerHTML={svgString}
39+
/>
40+
);
41+
},
42+
);
43+
44+
if (spriteSheetContainer.current) {
45+
return createPortal(renderedIcons, spriteSheetContainer.current);
46+
}
47+
48+
return (
49+
<svg id={spriteSheetId} style={hidden}>
50+
{renderedIcons}
51+
</svg>
52+
);
53+
}

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export const defaultInternalSpriteSheetId = '__SVG_SPRITE_SHEET__';
2+
export const isSSR = typeof document === 'undefined';

src/index.tsx

Lines changed: 24 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,20 @@
1+
import type { FC, ReactNode, SVGProps } from 'react';
12
import React, {
23
createContext,
34
useCallback,
45
useContext,
56
useEffect,
67
useMemo,
7-
useRef,
88
useState,
99
} from 'react';
10-
import type { FC } from 'react';
11-
import { createPortal } from 'react-dom';
12-
import { defaultInternalSpriteSheetId } from './constants';
10+
import { defaultInternalSpriteSheetId, isSSR } from './constants';
11+
import { SpriteSheet } from './SpriteSheet';
12+
import { IconData } from './types';
1313

14-
const isSSR = typeof document === 'undefined';
14+
export { IconData };
1515

1616
const globalIconsCache: IconsCache = new Map();
1717

18-
export interface IconData {
19-
id: string;
20-
svgString: { __html: string };
21-
attributes: { [key: string]: string };
22-
}
23-
2418
const noop = () => undefined;
2519

2620
interface SpriteContextValue {
@@ -47,7 +41,7 @@ const mapAttributes = (rawAttributes: string) => {
4741
return attributes;
4842
};
4943

50-
export type IconsCache = Map<string, Promise<IconData | undefined>>;
44+
export type IconsCache = Map<string, IconData | Promise<IconData | undefined>>;
5145

5246
let localIconsList: IconData[] = [];
5347

@@ -112,7 +106,7 @@ const registerIconInCache = (
112106
};
113107

114108
const useIcons = () => {
115-
const [icons, setIcons] = useState<IconData[]>([]);
109+
const [icons, setIcons] = useState<IconData[]>(localIconsList);
116110

117111
useEffect(() => {
118112
addListener(setIcons);
@@ -132,6 +126,7 @@ export interface SpriteContext {
132126
loadSVG: (url: string) => Promise<string | undefined>;
133127
knownIcons?: IconsCache;
134128
embeddedSSR?: boolean;
129+
children?: ReactNode;
135130
}
136131

137132
export const SpriteContextProvider: FC<SpriteContext> = ({
@@ -155,17 +150,28 @@ export const SpriteContextProvider: FC<SpriteContext> = ({
155150
[knownIcons, loadSVG],
156151
);
157152

158-
const contextValue = useMemo(() => ({ registerSVG }), [registerSVG]);
153+
const { registerSVG: wrappingContextRegisterSVG } = useContext(spriteContext);
154+
const contextValue = useMemo(
155+
() => ({
156+
registerSVG:
157+
wrappingContextRegisterSVG === noop
158+
? registerSVG
159+
: wrappingContextRegisterSVG,
160+
}),
161+
[registerSVG, wrappingContextRegisterSVG],
162+
);
159163

160164
return (
161165
<spriteContext.Provider value={contextValue}>
162166
{children}
163-
{(!isSSR || embeddedSSR) && <SpriteSheet icons={icons}></SpriteSheet>}
167+
{(!isSSR || embeddedSSR) && (
168+
<SpriteSheet embeddedSSR={embeddedSSR} icons={icons}></SpriteSheet>
169+
)}
164170
</spriteContext.Provider>
165171
);
166172
};
167173

168-
export const Icon: FC<{ url: string } & React.SVGProps<SVGSVGElement>> = ({
174+
export const Icon: FC<{ url: string } & SVGProps<SVGSVGElement>> = ({
169175
url,
170176
...props
171177
}) => {
@@ -187,49 +193,6 @@ export const Icon: FC<{ url: string } & React.SVGProps<SVGSVGElement>> = ({
187193
);
188194
};
189195

190-
const hidden = {
191-
height: 0,
192-
width: 0,
193-
position: 'absolute',
194-
visibility: 'hidden',
195-
} as const;
196-
export const SpriteSheet: FC<{
197-
icons: IconData[];
198-
spriteSheetId?: string;
199-
}> = ({ icons, spriteSheetId = defaultInternalSpriteSheetId }) => {
200-
const spriteSheetContainer = useRef(
201-
!isSSR ? document.getElementById(spriteSheetId) : null,
202-
);
203-
204-
const renderedIcons = icons.map(
205-
({
206-
id,
207-
svgString,
208-
attributes: { width, height, ['xmlns:xlink']: xmlnsXlink, ...attributes },
209-
}) => {
210-
return (
211-
<symbol
212-
key={id}
213-
id={id}
214-
xmlnsXlink={xmlnsXlink}
215-
{...attributes}
216-
dangerouslySetInnerHTML={svgString}
217-
/>
218-
);
219-
},
220-
);
221-
222-
if (spriteSheetContainer.current) {
223-
return createPortal(renderedIcons, spriteSheetContainer.current);
224-
}
225-
226-
return (
227-
<svg id={spriteSheetId} style={hidden}>
228-
{renderedIcons}
229-
</svg>
230-
);
231-
};
232-
233196
const mapNodeAttributes = (rawAttributes: NamedNodeMap) =>
234197
Array.from(rawAttributes).reduce<IconData['attributes']>(
235198
(attributes, current) => {
@@ -246,15 +209,12 @@ export const initOnClient = (
246209
) => {
247210
knownIcons.clear();
248211
const spriteSheet = document.getElementById(spriteSheetId);
212+
249213
if (spriteSheet) {
250-
const serializer = new XMLSerializer();
251214
const sprites = Array.from(spriteSheet.querySelectorAll('symbol'));
252215

253216
for (const node of sprites) {
254-
const innerHTML = Array.prototype.map
255-
.call(node.childNodes, (child) => serializer.serializeToString(child))
256-
.join('');
257-
217+
const innerHTML = node.innerHTML;
258218
const { id, attributes: rawAttributes } = node;
259219
const attributes = mapNodeAttributes(rawAttributes);
260220
const iconData = {

src/ssr.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import React from 'react';
2-
import { renderToStaticMarkup } from 'react-dom/server';
3-
import { IconData, IconsCache, SpriteSheet } from './index';
2+
import { renderToStaticMarkup, renderToString } from 'react-dom/server';
43
import { defaultInternalSpriteSheetId } from './constants';
4+
import { IconsCache } from './index';
5+
import { SpriteSheet } from './SpriteSheet';
6+
import { IconData } from './types';
57

68
export const createSpriteSheetString = (knownIcons: IconsCache) => {
79
return Promise.all(Array.from(knownIcons.values())).then((icons) =>
@@ -11,13 +13,17 @@ export const createSpriteSheetString = (knownIcons: IconsCache) => {
1113
);
1214
};
1315

16+
const emptyIconsCache: IconData[] = [];
17+
1418
export const renderSpriteSheetToString = (
1519
markupString: string,
1620
knownIcons: IconsCache,
1721
spriteSheetId = defaultInternalSpriteSheetId,
1822
) => {
1923
return createSpriteSheetString(knownIcons).then((spriteSheet) => {
20-
const ssrEmptySpriteSheet = `<svg id="${spriteSheetId}" style="display:none"></svg>`;
24+
const ssrEmptySpriteSheet = renderToString(
25+
<SpriteSheet icons={emptyIconsCache} spriteSheetId={spriteSheetId} />,
26+
);
2127
return markupString.replace(ssrEmptySpriteSheet, spriteSheet);
2228
});
2329
};

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface IconData {
2+
id: string;
3+
svgString: { __html: string };
4+
attributes: { [key: string]: string };
5+
}

test/index.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,6 @@ test('client should be able to initiate the cache from a rendered dom', async ()
8686
expect(iconData?.attributes.width).toBe(undefined);
8787
expect(iconData?.attributes.viewBox).toBe('0 0 24 24');
8888
expect(iconData?.svgString.__html).toBe(
89-
'<path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0z" fill="none"/>',
89+
'<path d="M0 0h24v24H0z" fill="none"></path>',
9090
);
9191
});

test/ssr.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ test('render loaded svgs to a svg sprite sheet string', async () => {
3838
);
3939

4040
expect(renderedSpriteSheet).toMatchInlineSnapshot(
41-
`"<svg><use xlink:href=\\"#1\\"></use></svg><svg id=\\"__SVG_SPRITE_SHEET__\\" style=\\"height:0;width:0;position:absolute;visibility:hidden\\"></svg>"`,
41+
`"<svg><use xlink:href=\\"#1\\"></use></svg><svg id=\\"__SVG_SPRITE_SHEET__\\" style=\\"height:0;width:0;position:absolute;visibility:hidden\\"><symbol id=\\"1\\" xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 24 24\\"><path d=\\"M0 0h24v24H0z\\" fill=\\"none\\"/></symbol></svg>"`,
4242
);
4343
});
4444

tsdx.config.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@ module.exports = {
2525
* @param {TsdxOptions} options
2626
*/
2727
rollup(config, options) {
28-
console.log('options input', options);
29-
3028
config.output = { dir: 'dist' };
3129
delete config.output.file;
3230
config.output.entryFileNames = `${safePackageName(options.name)}.[format]${

0 commit comments

Comments
 (0)