Skip to content

Commit d92ad6e

Browse files
committed
fix(hub-ui): retry transient icon requests
1 parent 7405628 commit d92ad6e

8 files changed

Lines changed: 583 additions & 43 deletions

File tree

packages/hub-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"dompurify": "catalog:frontend",
6767
"fuse.js": "catalog:frontend",
6868
"iframe-pane": "catalog:frontend",
69+
"jsdom": "catalog:testing",
6970
"storybook": "catalog:storybook",
7071
"tsdown": "catalog:build",
7172
"tsx": "catalog:build",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { createApp, h, nextTick, ref } from 'vue'
4+
import IconifyIcon from './IconifyIcon.vue'
5+
6+
const pending = vi.hoisted(() => new Map<string, (svg: string) => void>())
7+
vi.mock('../../utils/iconify', () => ({
8+
getIconifySvg: vi.fn((_collection: string, icon: string) => new Promise<string>((resolve) => {
9+
pending.set(icon, resolve)
10+
})),
11+
}))
12+
13+
const applications: ReturnType<typeof createApp>[] = []
14+
beforeEach(() => pending.clear())
15+
afterEach(() => {
16+
applications.splice(0).forEach(application => application.unmount())
17+
document.body.replaceChildren()
18+
})
19+
20+
describe('iconifyIcon async rendering', () => {
21+
it('renders a resolved icon on the same mount', async () => {
22+
expect.assertions(2)
23+
const container = document.createElement('div')
24+
document.body.append(container)
25+
const application = createApp(IconifyIcon, { icon: 'ph:first' })
26+
applications.push(application)
27+
application.mount(container)
28+
expect(container.querySelector('svg')).toBeNull()
29+
pending.get('first')!('<svg data-icon="first"/>')
30+
await Promise.resolve()
31+
await nextTick()
32+
expect(container.querySelector('svg')?.getAttribute('data-icon')).toBe('first')
33+
})
34+
35+
it('ignores an old response after the icon changes', async () => {
36+
expect.assertions(2)
37+
const icon = ref('ph:first')
38+
const container = document.createElement('div')
39+
const application = createApp({ render: () => h(IconifyIcon, { icon: icon.value }) })
40+
applications.push(application)
41+
application.mount(container)
42+
icon.value = 'ph:second'
43+
await nextTick()
44+
pending.get('second')!('<svg data-icon="second"/>')
45+
await Promise.resolve()
46+
await nextTick()
47+
expect(container.querySelector('svg')?.getAttribute('data-icon')).toBe('second')
48+
pending.get('first')!('<svg data-icon="first"/>')
49+
await Promise.resolve()
50+
await nextTick()
51+
expect(container.querySelector('svg')?.getAttribute('data-icon')).toBe('second')
52+
})
53+
})

packages/hub-ui/src/client/components/icons/IconifyIcon.vue

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,26 @@ const iconifyParsed = computed(() => {
2020
})
2121
2222
const iconifyLoaded = ref<string | undefined>(undefined)
23-
watchEffect(async () => {
23+
watchEffect(async (onCleanup) => {
24+
let active = true
25+
onCleanup(() => {
26+
active = false
27+
})
28+
iconifyLoaded.value = undefined
2429
if (!iconifyParsed.value) {
2530
iconifyLoaded.value = undefined
2631
return
2732
}
2833
try {
29-
iconifyLoaded.value = await getIconifySvg(iconifyParsed.value.collection, iconifyParsed.value.icon)
34+
const svg = await getIconifySvg(iconifyParsed.value.collection, iconifyParsed.value.icon)
35+
if (active)
36+
iconifyLoaded.value = svg
3037
}
3138
catch {
3239
// A failed icon fetch (offline / flaky CDN) should degrade to a blank icon,
3340
// not throw out of the async effect and crash the surrounding panel.
34-
iconifyLoaded.value = undefined
41+
if (active)
42+
iconifyLoaded.value = undefined
3543
}
3644
})
3745
</script>
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M0 0h24v24H0z"/></svg>'
5+
6+
beforeEach(() => {
7+
vi.resetModules()
8+
vi.useFakeTimers()
9+
})
10+
11+
afterEach(() => {
12+
vi.unstubAllGlobals()
13+
vi.restoreAllMocks()
14+
vi.useRealTimers()
15+
})
16+
17+
describe('remote icons', () => {
18+
it('recovers the actual mounted component after a transient failure', async () => {
19+
expect.assertions(3)
20+
const fetchMock = vi.fn().mockRejectedValueOnce(new TypeError('offline')).mockResolvedValue(new Response(svg))
21+
vi.stubGlobal('fetch', fetchMock)
22+
const { createApp, nextTick } = await import('vue')
23+
const { default: IconifyIcon } = await import('../components/icons/IconifyIcon.vue')
24+
const container = document.createElement('div')
25+
const application = createApp(IconifyIcon, { icon: 'ph:robot-duotone' })
26+
application.mount(container)
27+
try {
28+
expect(container.querySelector('svg')).toBeNull()
29+
await vi.advanceTimersByTimeAsync(500)
30+
await nextTick()
31+
expect(container.querySelector('svg')).not.toBeNull()
32+
expect(fetchMock).toHaveBeenCalledTimes(2)
33+
}
34+
finally {
35+
application.unmount()
36+
}
37+
})
38+
39+
it('lets a shared request finish after one consumer unmounts', async () => {
40+
expect.assertions(3)
41+
const fetchMock = vi.fn().mockRejectedValueOnce(new TypeError('offline')).mockResolvedValue(new Response(svg))
42+
vi.stubGlobal('fetch', fetchMock)
43+
const { createApp, nextTick } = await import('vue')
44+
const { default: IconifyIcon } = await import('../components/icons/IconifyIcon.vue')
45+
const firstContainer = document.createElement('div')
46+
const secondContainer = document.createElement('div')
47+
const first = createApp(IconifyIcon, { icon: 'ph:robot-duotone' })
48+
const second = createApp(IconifyIcon, { icon: 'ph:robot-duotone' })
49+
first.mount(firstContainer)
50+
second.mount(secondContainer)
51+
first.unmount()
52+
try {
53+
await vi.advanceTimersByTimeAsync(500)
54+
await nextTick()
55+
expect(firstContainer.innerHTML).toBe('')
56+
expect(secondContainer.querySelector('svg')).not.toBeNull()
57+
expect(fetchMock).toHaveBeenCalledTimes(2)
58+
}
59+
finally {
60+
second.unmount()
61+
}
62+
})
63+
64+
it('shares retries and caches the successful SVG', async () => {
65+
expect.assertions(6)
66+
const fetchMock = vi.fn().mockRejectedValueOnce(new TypeError('offline')).mockResolvedValue(new Response(svg))
67+
vi.stubGlobal('fetch', fetchMock)
68+
const { getIconifySvg } = await import('./iconify')
69+
const first = getIconifySvg('ph', 'robot-duotone')
70+
const second = getIconifySvg('ph', 'robot-duotone')
71+
await vi.advanceTimersByTimeAsync(499)
72+
expect(fetchMock).toHaveBeenCalledTimes(1)
73+
await vi.advanceTimersByTimeAsync(1)
74+
expect(await first).toContain('<svg')
75+
expect(await second).toBe(await first)
76+
expect(fetchMock).toHaveBeenCalledTimes(2)
77+
expect(await getIconifySvg('ph', 'robot-duotone')).toBe(await first)
78+
expect(fetchMock).toHaveBeenCalledTimes(2)
79+
})
80+
81+
it.each([408, 429, 500, 503])('retries HTTP %i', async (status) => {
82+
expect.assertions(2)
83+
const fetchMock = vi.fn().mockResolvedValueOnce(new Response('', { status })).mockResolvedValue(new Response(svg))
84+
vi.stubGlobal('fetch', fetchMock)
85+
const { getIconifySvg } = await import('./iconify')
86+
const result = getIconifySvg('ph', 'robot-duotone')
87+
await vi.advanceTimersByTimeAsync(500)
88+
expect(await result).toContain('<svg')
89+
expect(fetchMock).toHaveBeenCalledTimes(2)
90+
})
91+
92+
it('exhausts three attempts and allows a later request', async () => {
93+
expect.assertions(5)
94+
const fetchMock = vi.fn().mockRejectedValue(new TypeError('offline'))
95+
vi.stubGlobal('fetch', fetchMock)
96+
const { getIconifySvg } = await import('./iconify')
97+
const result = expect(getIconifySvg('ph', 'robot-duotone')).rejects.toThrow('offline')
98+
await vi.advanceTimersByTimeAsync(500)
99+
expect(fetchMock).toHaveBeenCalledTimes(2)
100+
await vi.advanceTimersByTimeAsync(1499)
101+
expect(fetchMock).toHaveBeenCalledTimes(2)
102+
await vi.advanceTimersByTimeAsync(1)
103+
await result
104+
expect(fetchMock).toHaveBeenCalledTimes(3)
105+
fetchMock.mockResolvedValue(new Response(svg))
106+
expect(await getIconifySvg('ph', 'robot-duotone')).toContain('<svg')
107+
})
108+
109+
it.each([400, 403, 404])('does not retry or cache HTTP %i', async (status) => {
110+
expect.assertions(3)
111+
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(svg, { status })).mockResolvedValue(new Response(svg))
112+
vi.stubGlobal('fetch', fetchMock)
113+
const { getIconifySvg } = await import('./iconify')
114+
await expect(getIconifySvg('ph', 'robot-duotone')).rejects.toThrow()
115+
expect(fetchMock).toHaveBeenCalledTimes(1)
116+
expect(await getIconifySvg('ph', 'robot-duotone')).toContain('<svg')
117+
})
118+
119+
it.each(['', '<html>Unavailable</html>', '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'])('rejects invalid SVG without caching it', async (body) => {
120+
expect.assertions(3)
121+
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(body)).mockResolvedValue(new Response(svg))
122+
vi.stubGlobal('fetch', fetchMock)
123+
const { getIconifySvg } = await import('./iconify')
124+
await expect(getIconifySvg('ph', 'robot-duotone')).rejects.toThrow()
125+
expect(fetchMock).toHaveBeenCalledTimes(1)
126+
expect(await getIconifySvg('ph', 'robot-duotone')).toContain('<svg')
127+
})
128+
129+
it('bounds each attempt to ten seconds', async () => {
130+
expect.assertions(3)
131+
const timeout = vi.spyOn(AbortSignal, 'timeout')
132+
const fetchMock = vi.fn().mockRejectedValueOnce(new DOMException('timeout', 'TimeoutError')).mockResolvedValue(new Response(svg))
133+
vi.stubGlobal('fetch', fetchMock)
134+
const { getIconifySvg } = await import('./iconify')
135+
const result = getIconifySvg('ph', 'robot-duotone')
136+
await vi.advanceTimersByTimeAsync(500)
137+
expect(await result).toContain('<svg')
138+
expect(timeout).toHaveBeenCalledTimes(2)
139+
expect(timeout).toHaveBeenNthCalledWith(2, 10_000)
140+
})
141+
142+
it('sanitizes executable content while preserving currentColor', async () => {
143+
expect.assertions(3)
144+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(svg.replace('<path ', '<script>alert(1)</script><path onclick="alert(1)" '))))
145+
const { getIconifySvg } = await import('./iconify')
146+
const result = await getIconifySvg('ph', 'robot-duotone')
147+
expect(result).toContain('currentColor')
148+
expect(result).not.toContain('<script')
149+
expect(result).not.toContain('onclick')
150+
})
151+
})

packages/hub-ui/src/client/utils/iconify.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,51 @@ const getIconifySvgMap = new Map<string, Promise<string> | string>()
44

55
const purify = createDOMPurify()
66

7+
function sanitizeSvg(svg: string): string {
8+
const sanitized = purify.sanitize(svg)
9+
const document = new DOMParser().parseFromString(sanitized, 'image/svg+xml')
10+
if (document.documentElement.localName !== 'svg'
11+
|| document.querySelector('parsererror')
12+
|| !document.querySelector('path, rect, circle, ellipse, polygon, polyline, line, text, use, image')) {
13+
throw new Error('Invalid icon SVG')
14+
}
15+
return sanitized
16+
}
17+
18+
async function fetchIconSvg(url: string): Promise<string> {
19+
const delays = [500, 1500]
20+
for (let attempt = 0; ; attempt++) {
21+
let response: Response
22+
let body: string | undefined
23+
try {
24+
response = await fetch(url, { signal: AbortSignal.timeout(10_000) })
25+
if (response.ok)
26+
body = await response.text()
27+
}
28+
catch (error) {
29+
if (attempt >= delays.length)
30+
throw error
31+
await new Promise(resolve => setTimeout(resolve, delays[attempt]))
32+
continue
33+
}
34+
35+
if (body !== undefined)
36+
return sanitizeSvg(body)
37+
38+
const retryable = response.status === 408 || response.status === 429 || response.status >= 500
39+
if (!retryable || attempt >= delays.length)
40+
throw new Error(`Icon request failed: HTTP ${response.status}`)
41+
await new Promise(resolve => setTimeout(resolve, delays[attempt]))
42+
}
43+
}
44+
745
export async function getIconifySvg(collection: string, icon: string) {
846
const id = `${collection}:${icon}`
947
if (getIconifySvgMap.has(id)) {
1048
return getIconifySvgMap.get(id)!
1149
}
12-
const promise = _get()
50+
const url = `https://api.iconify.design/${collection}/${icon}.svg?color=currentColor&width=100%`
51+
const promise = fetchIconSvg(url)
1352
.then((svg) => {
1453
getIconifySvgMap.set(id, svg)
1554
return svg
@@ -21,13 +60,4 @@ export async function getIconifySvg(collection: string, icon: string) {
2160
})
2261
getIconifySvgMap.set(id, promise)
2362
return promise
24-
25-
async function _get() {
26-
const url = `https://api.iconify.design/${collection}/${icon}.svg?color=currentColor&width=100%`
27-
// Bound the request so a stalled connection (offline / flaky CDN / firewall
28-
// black-holing the host) rejects instead of hanging forever; the caller
29-
// already degrades a rejected fetch to a blank icon.
30-
const svg = await fetch(url, { signal: AbortSignal.timeout(10_000) }).then(res => res.text())
31-
return purify.sanitize(svg)
32-
}
3363
}

packages/hub-ui/vitest.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Vue from '@vitejs/plugin-vue'
2+
import { defineProject } from 'vitest/config'
3+
import { alias } from '../../alias'
4+
5+
export default defineProject({
6+
plugins: [Vue()],
7+
resolve: { alias },
8+
test: { name: 'hub-ui' },
9+
})

0 commit comments

Comments
 (0)