Skip to content

Commit 1f99511

Browse files
committed
fix(hub-ui): show failed icons and retry on remount
1 parent 7405628 commit 1f99511

6 files changed

Lines changed: 218 additions & 14 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import process from 'node:process'
2+
import { defineConfig } from '@playwright/test'
3+
4+
export default defineConfig({
5+
testDir: './tests',
6+
testMatch: '*.browser.ts',
7+
use: { baseURL: 'http://127.0.0.1:5199', viewport: { width: 1280, height: 800 } },
8+
webServer: {
9+
command: 'pnpm exec vite --config playground/vite.config.ts --host 127.0.0.1 --port 5199 --strictPort',
10+
url: 'http://127.0.0.1:5199',
11+
reuseExistingServer: !process.env.CI,
12+
env: { DEVFRAME_DISABLE_INSTANCE_REGISTRY: '1' },
13+
},
14+
})

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

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,35 @@ const iconifyParsed = computed(() => {
2020
})
2121
2222
const iconifyLoaded = ref<string | undefined>(undefined)
23-
watchEffect(async () => {
24-
if (!iconifyParsed.value) {
25-
iconifyLoaded.value = undefined
23+
const failed = ref(false)
24+
watchEffect(async (onCleanup) => {
25+
let active = true
26+
onCleanup(() => {
27+
active = false
28+
})
29+
iconifyLoaded.value = undefined
30+
failed.value = false
31+
if (!iconifyParsed.value)
2632
return
27-
}
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 {
32-
// A failed icon fetch (offline / flaky CDN) should degrade to a blank icon,
33-
// not throw out of the async effect and crash the surrounding panel.
34-
iconifyLoaded.value = undefined
39+
if (active)
40+
failed.value = true
3541
}
3642
})
3743
</script>
3844

3945
<template>
46+
<svg v-if="failed" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="w-full h-full">
47+
<rect x="3" y="3" width="18" height="18" rx="3" />
48+
<path d="M12 7v6m0 3v1" />
49+
</svg>
4050
<div
41-
v-if="iconifyParsed"
51+
v-else-if="iconifyParsed"
4252
v-html="iconifyLoaded"
4353
/>
4454
<img

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,16 @@ export async function getIconifySvg(collection: string, icon: string) {
2424

2525
async function _get() {
2626
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)
27+
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) })
28+
if (!response.ok)
29+
throw new Error(`Iconify request failed: ${response.status}`)
30+
const svg = purify.sanitize(await response.text())
31+
const document = new DOMParser().parseFromString(svg, 'image/svg+xml')
32+
if (document.documentElement.localName !== 'svg'
33+
|| document.querySelector('parsererror')
34+
|| !document.querySelector('path, circle, ellipse, rect, line, polyline, polygon, text, use, image')) {
35+
throw new Error('Iconify returned an invalid SVG')
36+
}
37+
return svg
3238
}
3339
}

packages/hub-ui/tests/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Icon browser checks
2+
3+
From the repository root:
4+
5+
```sh
6+
pnpm exec turbo run build --filter=@devframes/plugin-git...
7+
pnpm exec playwright install chromium
8+
pnpm exec playwright test --config packages/hub-ui/playwright.config.ts
9+
```
10+
11+
The suite starts the existing hub-ui playground, intercepts Iconify requests, and tests the real browser component and cache. A fixed 1280×800 viewport produces `*-failure.png` and `*-recovered.png` under `packages/hub-ui/test-results/` for the embedded dock and standalone SPA. The failure screenshot uses HTTP 503; the recovery screenshot reloads with a deterministic SVG response. No external Iconify service is needed.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { fileURLToPath } from 'node:url'
2+
import { expect, test } from '@playwright/test'
3+
4+
const fixtureUrl = `/@fs${fileURLToPath(new URL('./icons.fixture.ts', import.meta.url))}`
5+
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="100%" height="100%"><path fill="currentColor" d="M4 12l5 5L20 6"/><script>alert(1)</script></svg>'
6+
7+
test.beforeEach(async ({ page }) => {
8+
await page.route('https://api.iconify.design/**', route => route.fulfill({ body: svg, contentType: 'image/svg+xml' }))
9+
await page.goto('/')
10+
await expect(page.getByRole('button', { name: 'No Renderer', exact: true })).toBeVisible()
11+
})
12+
13+
test('shares pending requests, sanitizes SVG and caches success', async ({ page }) => {
14+
let requests = 0
15+
await page.route('**/test/shared.svg?*', async (route) => {
16+
requests++
17+
await route.fulfill({ body: svg })
18+
})
19+
const results = await page.evaluate(async (url) => {
20+
const { getIconifySvg } = await import(url)
21+
const values = await Promise.all([getIconifySvg('test', 'shared'), getIconifySvg('test', 'shared')])
22+
return [...values, await getIconifySvg('test', 'shared')]
23+
}, fixtureUrl)
24+
expect(requests).toBe(1)
25+
expect(new Set(results).size).toBe(1)
26+
expect(results[0]).toContain('currentColor')
27+
expect(results[0]).not.toContain('<script')
28+
})
29+
30+
for (const failure of [404, 429, 500, 'invalid', 'empty', 'network', 'timeout'] as const) {
31+
test(`evicts ${failure} failures and makes no automatic retry`, async ({ page }) => {
32+
const result = await page.evaluate(async ({ url, failure, svg }) => {
33+
const { getIconifySvg } = await import(url)
34+
const originalFetch = window.fetch
35+
let calls = 0
36+
let timeout = 0
37+
const originalTimeout = AbortSignal.timeout
38+
AbortSignal.timeout = (milliseconds) => {
39+
timeout = milliseconds
40+
return originalTimeout(1)
41+
}
42+
window.fetch = async (_input, options) => {
43+
if (++calls > 1)
44+
return new Response(svg)
45+
if (failure === 'network')
46+
throw new TypeError('offline')
47+
if (failure === 'timeout') {
48+
await new Promise(resolve => options!.signal!.addEventListener('abort', resolve, { once: true }))
49+
throw options!.signal!.reason
50+
}
51+
return typeof failure === 'number'
52+
? new Response(svg, { status: failure })
53+
: new Response(failure === 'empty' ? '<svg xmlns="http://www.w3.org/2000/svg"/>' : '<html>error</html>')
54+
}
55+
try {
56+
const first = await getIconifySvg('test', 'failure').then(() => 'success', () => 'failed')
57+
const callsAfterFailure = calls
58+
const second = await getIconifySvg('test', 'failure')
59+
return { first, callsAfterFailure, calls, timeout, second }
60+
}
61+
finally {
62+
window.fetch = originalFetch
63+
AbortSignal.timeout = originalTimeout
64+
}
65+
}, { url: fixtureUrl, failure, svg })
66+
expect(result.first).toBe('failed')
67+
expect(result.callsAfterFailure).toBe(1)
68+
expect(result.calls).toBe(2)
69+
expect(result.timeout).toBe(10_000)
70+
expect(result.second).toContain('<svg')
71+
})
72+
}
73+
74+
test('ignores stale component results without cancelling shared requests', async ({ page }) => {
75+
const result = await page.evaluate(async ({ url, svg }) => {
76+
const { createApp, h, ref, nextTick, IconifyIcon, getIconifySvg } = await import(url)
77+
const originalFetch = window.fetch
78+
let resolveFetch!: (response: Response) => void
79+
window.fetch = () => new Promise<Response>((resolve) => {
80+
resolveFetch = resolve
81+
})
82+
const source = ref('test:pending')
83+
const firstContainer = document.createElement('div')
84+
const secondContainer = document.createElement('div')
85+
const removedContainer = document.createElement('div')
86+
const first = createApp({ render: () => h(IconifyIcon, { icon: source.value }) })
87+
const second = createApp(IconifyIcon, { icon: 'test:pending' })
88+
const removed = createApp(IconifyIcon, { icon: 'test:pending' })
89+
removed.mount(removedContainer)
90+
removed.unmount()
91+
first.mount(firstContainer)
92+
second.mount(secondContainer)
93+
source.value = 'data:image/svg+xml,<svg/>'
94+
await nextTick()
95+
resolveFetch(new Response(svg))
96+
await getIconifySvg('test', 'pending')
97+
await nextTick()
98+
const changed = firstContainer.innerHTML
99+
const shared = secondContainer.innerHTML
100+
first.unmount()
101+
second.unmount()
102+
window.fetch = originalFetch
103+
return { changed, shared, removed: removedContainer.innerHTML }
104+
}, { url: fixtureUrl, svg })
105+
expect(result.changed).toContain('<img')
106+
expect(result.changed).not.toContain('<path')
107+
expect(result.shared).toContain('<path')
108+
expect(result.removed).toBe('')
109+
})
110+
111+
test('retries on a later mount and replaces the fallback', async ({ page }) => {
112+
const result = await page.evaluate(async ({ url, svg }) => {
113+
const { createApp, nextTick, IconifyIcon, getIconifySvg } = await import(url)
114+
const originalFetch = window.fetch
115+
let calls = 0
116+
window.fetch = async () => {
117+
if (++calls === 1)
118+
throw new TypeError('offline')
119+
return new Response(svg)
120+
}
121+
const container = document.createElement('div')
122+
const first = createApp(IconifyIcon, { icon: 'test:remount' })
123+
first.mount(container)
124+
await getIconifySvg('test', 'remount').catch(() => {})
125+
await nextTick()
126+
const fallback = container.querySelector('svg')?.getAttribute('aria-hidden')
127+
first.unmount()
128+
const second = createApp(IconifyIcon, { icon: 'test:remount' })
129+
second.mount(container)
130+
await getIconifySvg('test', 'remount')
131+
await nextTick()
132+
const recovered = container.innerHTML
133+
second.unmount()
134+
window.fetch = originalFetch
135+
return { fallback, recovered, calls }
136+
}, { url: fixtureUrl, svg })
137+
expect(result.fallback).toBe('true')
138+
expect(result.recovered).toContain('M4 12l5 5L20 6')
139+
expect(result.calls).toBe(2)
140+
})
141+
142+
for (const mode of ['standalone', 'embedded']) {
143+
test(`fallback and reload recovery screenshots: ${mode}`, async ({ page }, testInfo) => {
144+
await page.route('https://api.iconify.design/**', route => route.fulfill({ status: 503, body: 'Unavailable' }))
145+
await page.goto(mode === 'embedded' ? '/?embedded' : '/')
146+
const button = page.getByRole('button', { name: 'Settings', exact: true })
147+
await expect(button.locator('svg[aria-hidden="true"]')).toBeVisible()
148+
await button.click()
149+
await expect(button).toBeEnabled()
150+
await page.screenshot({ animations: 'disabled', path: testInfo.outputPath(`${mode}-failure.png`) })
151+
await page.unroute('https://api.iconify.design/**')
152+
await page.route('https://api.iconify.design/**', route => route.fulfill({ body: svg, contentType: 'image/svg+xml' }))
153+
await page.reload()
154+
await expect(button.locator('svg path')).toHaveAttribute('d', 'M4 12l5 5L20 6')
155+
await button.click()
156+
await expect(page.getByText('Color mode', { exact: true })).toBeVisible()
157+
await expect(button.locator('svg')).toBeVisible()
158+
await page.screenshot({ animations: 'disabled', path: testInfo.outputPath(`${mode}-recovered.png`) })
159+
})
160+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default as IconifyIcon } from '../src/client/components/icons/IconifyIcon.vue'
2+
export { getIconifySvg } from '../src/client/utils/iconify'
3+
export { createApp, h, nextTick, ref } from 'vue'

0 commit comments

Comments
 (0)