Skip to content

Commit 545f9e3

Browse files
fix(Icon): render bundled icons during SSR in Vue (#6841)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
1 parent 14ac243 commit 545f9e3

2 files changed

Lines changed: 71 additions & 2 deletions

File tree

src/runtime/vue/components/Icon.vue

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ type CustomizeFn = Exclude<IconProps['customize'], boolean | null | undefined>
77
88
<script setup lang="ts">
99
import { computed } from 'vue'
10-
import { Icon as IconifyIcon } from '@iconify/vue'
10+
import { Icon as IconifyIcon, iconLoaded } from '@iconify/vue'
1111
import { useAppConfig } from '#imports'
1212
1313
const props = defineProps<IconProps>()
@@ -32,16 +32,23 @@ const mode = computed(() => {
3232
const size = computed(() => props.size || appConfig.icon?.size)
3333
3434
const customize = computed(() => resolveCustomizeFn(props.customize, appConfig.icon?.customize))
35+
36+
const icon = computed(() => typeof props.name === 'string' ? props.name.replace(/^i-/, '') : '')
37+
38+
// `@iconify/vue` only resolves icon data in `setup()` when `ssr` is set. Only opt in for icons
39+
// already in memory (bundled) so the others keep loading from the API on mount.
40+
const ssr = computed(() => iconLoaded(icon.value))
3541
</script>
3642

3743
<template>
3844
<IconifyIcon
3945
v-if="typeof name === 'string'"
40-
:icon="name.replace(/^i-/, '')"
46+
:icon="icon"
4147
:mode="mode"
4248
:width="size"
4349
:height="size"
4450
:customise="customize"
51+
:ssr="ssr"
4552
/>
4653
<component :is="name" v-else />
4754
</template>

test/components/Icon.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { h, createSSRApp } from 'vue'
3+
import { renderToString } from 'vue/server-renderer'
4+
import { mountSuspended } from '@nuxt/test-utils/runtime'
5+
import { addIcon, setCustomIconLoader } from '@iconify/vue'
6+
import Icon from '../../src/runtime/vue/components/Icon.vue'
7+
8+
function renderIconToString(name: string) {
9+
return renderToString(createSSRApp({ render: () => h(Icon, { name, mode: 'svg' }) }))
10+
}
11+
12+
describe('Icon', () => {
13+
it('outputs the real icon body when server-rendering a preloaded icon', async () => {
14+
addIcon('mock:server-icon', {
15+
body: '<path d="M2 2h20v20H2z" />',
16+
width: 24,
17+
height: 24
18+
})
19+
20+
const html = await renderIconToString('i-mock-server-icon')
21+
22+
expect(html).toContain('<path')
23+
})
24+
25+
it('keeps working on the client for a preloaded icon', async () => {
26+
addIcon('mock:client-icon', {
27+
body: '<path d="M2 2h20v20H2z" />',
28+
width: 24,
29+
height: 24
30+
})
31+
32+
const wrapper = await mountSuspended(Icon, {
33+
props: { name: 'i-mock-client-icon', mode: 'svg' }
34+
})
35+
36+
expect(wrapper.html()).toContain('<path')
37+
})
38+
39+
it('falls back to an empty svg on the client when the icon is unknown', async () => {
40+
// null loader on a dedicated prefix so the lookup stays offline instead of hitting the Iconify API
41+
setCustomIconLoader(() => null, 'mock-missing')
42+
43+
const wrapper = await mountSuspended(Icon, {
44+
props: { name: 'i-mock-missing:icon', mode: 'svg' }
45+
})
46+
47+
expect(wrapper.html()).toContain('<svg')
48+
expect(wrapper.html()).not.toContain('<path')
49+
})
50+
51+
it('never asks the loader for an icon that is not preloaded during SSR', async () => {
52+
// isolated prefix so a load queued by another test's `onMounted` can't bleed into this assertion
53+
const fetchIcon = vi.fn(() => new Promise<never>(() => {}))
54+
setCustomIconLoader(fetchIcon, 'mock-ssr-remote')
55+
56+
await renderIconToString('i-mock-ssr-remote:icon')
57+
// the loader call is scheduled through `setTimeout`, give it a chance to run before checking
58+
await new Promise(resolve => setTimeout(resolve))
59+
60+
expect(fetchIcon).not.toHaveBeenCalled()
61+
})
62+
})

0 commit comments

Comments
 (0)