Skip to content

Commit 7913fb9

Browse files
authored
perf: lazy-load the iconify runtime on the client (#565)
1 parent f2a728a commit 7913fb9

17 files changed

Lines changed: 341 additions & 79 deletions

‎src/module.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import fs from 'node:fs/promises'
2-
import { defineNuxtModule, addPlugin, addServerHandler, hasNuxtModule, createResolver, addComponent, logger, updateTemplates, resolvePath as nuxtResolvePath, addVitePlugin, getNuxtVersion } from '@nuxt/kit'
2+
import { defineNuxtModule, addPlugin, addServerHandler, hasNuxtModule, createResolver, addComponent, addTemplate, logger, updateTemplates, resolvePath as nuxtResolvePath, addVitePlugin, getNuxtVersion } from '@nuxt/kit'
33
import { addCustomTab } from '@nuxt/devtools-kit'
44
import { resolvePath } from 'mlly'
55
import type { ViteDevServer } from 'vite'
@@ -93,6 +93,16 @@ export default defineNuxtModule<ModuleOptions>({
9393
global: true,
9494
filePath: await resolver.resolvePath('./runtime/components/index'),
9595
})
96+
const svgComponent = JSON.stringify(await resolver.resolvePath('./runtime/components/svg'))
97+
addTemplate({
98+
filename: 'nuxt-icon-svg.mjs',
99+
getContents: () => options.mode === 'svg'
100+
? `export { NuxtIconSvg } from ${svgComponent}`
101+
: [
102+
`import { defineAsyncComponent } from 'vue'`,
103+
`export const NuxtIconSvg = /* @__PURE__ */ defineAsyncComponent(() => import(${svgComponent}).then(r => r.NuxtIconSvg))`,
104+
].join('\n'),
105+
})
96106
if (usesLocalApi(options.provider)) {
97107
addServerHandler({
98108
route: `${options.localApiEndpoint || '/api/_nuxt_icon'}/:collection`,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { addIcon, getIcon } from '@iconify/vue'
2+
export { getCachedIconCSS } from './css-cache'
3+
export { loadIcon, setupIconify } from './shared'

‎src/runtime/components/css.ts‎

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
1-
import { addIcon, getIcon as _getIcon } from '@iconify/vue'
21
import { computed, watch, h, defineComponent } from 'vue'
32
import type { PropType } from 'vue'
43
import type { IconifyIcon } from '@iconify/types'
54
import type { NuxtIconRuntimeOptions, NuxtIconRuntimeServerOptions } from '../../types'
6-
import type { IconifyIconCustomizeCallback } from './shared'
5+
import type { IconifyIconCustomizeCallback } from './name'
76
import { useCustomizeHash } from './customize-hash'
8-
import { getCachedIconCSS } from './css-cache'
97
import { createIconSelectors, escapeCssSelector } from './css-selectors'
10-
import { loadIcon, resolveCustomizeFn } from './shared'
8+
import { resolveCustomizeFn } from './name'
119
import { insertIconStyle } from './style-order'
1210
import { useAppConfig, useNuxtApp, useHead, useRuntimeConfig, onServerPrefetch } from '#imports'
1311

12+
type CssRuntime = typeof import('./css-runtime')
13+
14+
let runtime: CssRuntime | undefined
15+
16+
function loadRuntime(): Promise<CssRuntime> {
17+
return runtime
18+
? Promise.resolve(runtime)
19+
: import('./css-runtime').then(r => runtime = r)
20+
}
21+
1422
// This should only be used in the client side
1523
let selectors: ReturnType<typeof createIconSelectors> | undefined
1624
const SYMBOL_SERVER_CSS = 'NUXT_ICONS_SERVER_CSS'
@@ -43,27 +51,27 @@ export const NuxtIconCss = /* @__PURE__ */ defineComponent({
4351
return base
4452
})
4553

46-
function getIcon(name: string) {
54+
function getIcon(rt: CssRuntime, name: string) {
4755
if (!name)
4856
return
49-
const icon = _getIcon(name)
57+
const icon = rt.getIcon(name)
5058
if (icon)
5159
return icon
5260
const payload = nuxt.payload?.data?.[name]
5361
if (payload) {
54-
addIcon(name, payload)
62+
rt.addIcon(name, payload)
5563
return payload
5664
}
5765
}
5866

5967
const selector = computed(() => '.' + escapeCssSelector(cssClass.value))
6068

61-
function getCSS(icon: IconifyIcon, withLayer = true, iconSelector = selector.value) {
69+
function getCSS(rt: CssRuntime, icon: IconifyIcon, withLayer = true, iconSelector = selector.value) {
6270
if (options.cssWherePseudo) {
6371
iconSelector = `:where(${iconSelector})`
6472
}
6573

66-
const css = getCachedIconCSS(icon, iconSelector, resolveCustomizeFn(props.customize, options.customize))
74+
const css = rt.getCachedIconCSS(icon, iconSelector, resolveCustomizeFn(props.customize, options.customize))
6775
if (options.cssLayer && withLayer) {
6876
return `@layer ${options.cssLayer} { ${css} }`
6977
}
@@ -75,13 +83,13 @@ export const NuxtIconCss = /* @__PURE__ */ defineComponent({
7583

7684
// Takes the name and selector the icon was requested for: the name can
7785
// change while the icon loads, and the rule must not go to the new name.
78-
async function mountCSS(icon: IconifyIcon, name: string, iconSelector: string) {
86+
async function mountCSS(rt: CssRuntime, icon: IconifyIcon, name: string, iconSelector: string) {
7987
if (knownSelectors.has(iconSelector))
8088
return
8189
if (typeof document === 'undefined')
8290
return
8391
const style = document.createElement('style')
84-
style.textContent = getCSS(icon, true, iconSelector)
92+
style.textContent = getCSS(rt, icon, true, iconSelector)
8593
if (import.meta.dev) {
8694
style.dataset.nuxtIconDev = name
8795
}
@@ -96,18 +104,24 @@ export const NuxtIconCss = /* @__PURE__ */ defineComponent({
96104
if (knownSelectors.has(iconSelector)) {
97105
return
98106
}
99-
const data = getIcon(name)
100-
if (data) {
101-
mountCSS(data, name, iconSelector)
102-
}
103-
else {
104-
loadIcon(name, import.meta.server ? options.fetchTimeout : -1)
105-
.then((data) => {
106-
if (data)
107-
mountCSS(data, name, iconSelector)
108-
})
109-
.catch(() => null)
107+
const mount = (rt: CssRuntime) => {
108+
const data = getIcon(rt, name)
109+
if (data) {
110+
mountCSS(rt, data, name, iconSelector)
111+
}
112+
else {
113+
rt.loadIcon(name, import.meta.server ? options.fetchTimeout : -1)
114+
.then((data) => {
115+
if (data)
116+
mountCSS(rt, data, name, iconSelector)
117+
})
118+
.catch(() => null)
119+
}
110120
}
121+
if (runtime)
122+
mount(runtime)
123+
else
124+
loadRuntime().then(mount).catch(() => null)
111125
},
112126
{ immediate: true },
113127
)
@@ -121,7 +135,8 @@ export const NuxtIconCss = /* @__PURE__ */ defineComponent({
121135
if (import.meta.server) {
122136
const configs = (useRuntimeConfig().icon || {}) as NuxtIconRuntimeServerOptions
123137
if (!configs?.serverKnownCssClasses?.includes(cssClass.value)) {
124-
const icon = await loadIcon(props.name, options.fetchTimeout).catch(() => null)
138+
const rt = await loadRuntime()
139+
const icon = await rt.loadIcon(props.name, options.fetchTimeout).catch(() => null)
125140
if (!icon)
126141
return null
127142
let ssrCSS: Map<string, string> = nuxt.vueApp._context.provides[SYMBOL_SERVER_CSS]
@@ -147,7 +162,7 @@ export const NuxtIconCss = /* @__PURE__ */ defineComponent({
147162
}
148163
// Dedupe CSS (use cssClass as key to support per-instance customization)
149164
if (cssClass.value && !ssrCSS.has(cssClass.value)) {
150-
const css = getCSS(icon, false)
165+
const css = getCSS(rt, icon, false)
151166
ssrCSS.set(cssClass.value, css)
152167
}
153168
return null

‎src/runtime/components/customize-hash.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { computed, getCurrentInstance, useId } from 'vue'
22
import { fnv1a64Base36 } from 'fnv1a-64'
3-
import type { IconifyIconCustomizeCallback } from './shared'
3+
import type { IconifyIconCustomizeCallback } from './name'
44
import { useState } from '#imports'
55

66
const hydratedHashes = new Map<string, string>()

‎src/runtime/components/index.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import { getIcon as _getIcon } from '@iconify/vue'
21
import type { PropType } from 'vue'
32
import { computed, defineComponent, h } from 'vue'
43
import type { NuxtIconRuntimeOptions } from '../../types'
54
import { NuxtIconCss } from './css'
6-
import { NuxtIconSvg } from './svg'
7-
import type { IconifyIconCustomizeCallback } from './shared'
8-
import { useResolvedName } from './shared'
5+
import type { IconifyIconCustomizeCallback } from './name'
6+
import { useResolvedName } from './name'
97
import { useAppConfig, useNuxtApp } from '#imports'
8+
import { NuxtIconSvg } from '#build/nuxt-icon-svg'
109

1110
export default defineComponent({
1211
name: 'NuxtIcon',

‎src/runtime/components/name.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { computed } from 'vue'
2+
import type { NuxtIconRuntimeOptions } from '../../types'
3+
import { useAppConfig } from '#imports'
4+
import { resolveName } from './resolve-name'
5+
6+
export type IconifyIconCustomizeCallback = (
7+
content: string,
8+
name?: string,
9+
prefix?: string,
10+
provider?: string,
11+
) => string
12+
13+
export function useResolvedName(getName: () => string) {
14+
const options = useAppConfig().icon as NuxtIconRuntimeOptions
15+
16+
return computed(() => resolveName(getName(), options))
17+
}
18+
19+
export function resolveCustomizeFn(
20+
customize: IconifyIconCustomizeCallback | boolean | null | undefined,
21+
globalCustomize: IconifyIconCustomizeCallback | undefined,
22+
):
23+
IconifyIconCustomizeCallback | undefined {
24+
if (customize === false) return undefined
25+
if (customize === true || customize === null) return globalCustomize
26+
return customize
27+
}

‎src/runtime/components/shared.ts‎

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,21 @@
1-
import { computed } from 'vue'
2-
import { getIcon as _getIcon, addIcon as _addIcon, loadIcon as _loadIcon } from '@iconify/vue'
1+
import { getIcon as _getIcon, addIcon as _addIcon, loadIcon as _loadIcon, addAPIProvider, _api, setCustomIconsLoader } from '@iconify/vue'
32
import type { IconifyIcon } from '@iconify/types'
4-
import type { NuxtIconRuntimeOptions } from '../../types'
5-
import { useAppConfig } from '#imports'
63
import { init as initClientBundle } from '#build/nuxt-icon-client-bundle'
7-
import { resolveName } from './resolve-name'
4+
import { applyIconifyConfig } from '../iconify-config'
85

96
export { initClientBundle }
7+
export { useResolvedName, resolveCustomizeFn } from './name'
8+
export type { IconifyIconCustomizeCallback } from './name'
109

11-
export type IconifyIconCustomizeCallback = (
12-
content: string,
13-
name?: string,
14-
prefix?: string,
15-
provider?: string,
16-
) => string
10+
export function setupIconify() {
11+
applyIconifyConfig({ addAPIProvider, _api, setCustomIconsLoader })
12+
}
1713

1814
export async function loadIcon(name: string, timeout: number): Promise<Required<IconifyIcon> | null | undefined> {
1915
if (!name)
2016
return null
2117
initClientBundle(_addIcon)
18+
setupIconify()
2219
const _icon = _getIcon(name)
2320
if (_icon)
2421
return _icon
@@ -45,19 +42,3 @@ export async function loadIcon(name: string, timeout: number): Promise<Required<
4542

4643
return _getIcon(name)
4744
}
48-
49-
export function useResolvedName(getName: () => string) {
50-
const options = useAppConfig().icon as NuxtIconRuntimeOptions
51-
52-
return computed(() => resolveName(getName(), options))
53-
}
54-
55-
export function resolveCustomizeFn(
56-
customize: IconifyIconCustomizeCallback | boolean | null | undefined,
57-
globalCustomize: IconifyIconCustomizeCallback | undefined,
58-
):
59-
IconifyIconCustomizeCallback | undefined {
60-
if (customize === false) return undefined
61-
if (customize === true || customize === null) return globalCustomize
62-
return customize
63-
}

‎src/runtime/components/svg.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { h } from 'vue'
33
import type { PropType } from 'vue'
44
import type { NuxtIconRuntimeOptions } from '../../types'
55
import type { IconifyIconCustomizeCallback } from './shared'
6-
import { initClientBundle, loadIcon, useResolvedName, resolveCustomizeFn } from './shared'
6+
import { initClientBundle, loadIcon, setupIconify, useResolvedName, resolveCustomizeFn } from './shared'
77
import { useAsyncData, useNuxtApp, defineComponent, useAppConfig, onServerPrefetch } from '#imports'
88

99
export const NuxtIconSvg = /* @__PURE__ */ defineComponent({
@@ -20,6 +20,7 @@ export const NuxtIconSvg = /* @__PURE__ */ defineComponent({
2020
},
2121
},
2222
setup(props, { slots }) {
23+
setupIconify()
2324
const nuxt = useNuxtApp()
2425
const options = useAppConfig().icon as NuxtIconRuntimeOptions
2526
const name = useResolvedName(() => props.name)

‎src/runtime/iconify-config.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { addAPIProvider, _api, setCustomIconsLoader, IconifyCustomIconsLoader } from '@iconify/vue'
2+
3+
export interface IconifyConfigTarget {
4+
addAPIProvider: typeof addAPIProvider
5+
_api: typeof _api
6+
setCustomIconsLoader: typeof setCustomIconsLoader
7+
}
8+
9+
export interface IconifyConfig {
10+
/**
11+
* API endpoints of the default provider. When empty, the API is not used at all.
12+
*/
13+
resources: string[]
14+
fetch: typeof globalThis.fetch
15+
customIconsLoader: IconifyCustomIconsLoader
16+
customCollections: string[]
17+
}
18+
19+
const DEFAULT_RESOURCES = ['https://api.iconify.design']
20+
21+
let pending: IconifyConfig | undefined
22+
let applied: IconifyConfig | undefined
23+
let appliedFetch: unknown
24+
let appliedProvider: unknown
25+
26+
/**
27+
* Register the Iconify API configuration, applied once `@iconify/vue` is loaded.
28+
*/
29+
export function setIconifyConfig(config: IconifyConfig) {
30+
pending = config
31+
}
32+
33+
/**
34+
* Apply the registered configuration. On the client, a fetch function or default
35+
* provider that the app has already set on `@iconify/vue` is kept.
36+
*/
37+
export function applyIconifyConfig({ addAPIProvider, _api, setCustomIconsLoader }: IconifyConfigTarget) {
38+
if (!pending || applied === pending)
39+
return
40+
const config = applied = pending
41+
42+
const fetch = _api.getFetch()
43+
if (import.meta.server || !config.resources.length || fetch === globalThis.fetch || fetch === appliedFetch) {
44+
_api.setFetch(config.fetch)
45+
appliedFetch = config.fetch
46+
}
47+
48+
const provider = _api.getAPIConfig('')
49+
const isDefaultProvider = !provider || provider === appliedProvider
50+
|| (provider.resources.length === DEFAULT_RESOURCES.length && provider.resources.every((r, i) => r === DEFAULT_RESOURCES[i]))
51+
if (config.resources.length && (import.meta.server || isDefaultProvider)) {
52+
addAPIProvider('', { resources: config.resources })
53+
appliedProvider = _api.getAPIConfig('')
54+
}
55+
56+
for (const prefix of config.customCollections) {
57+
if (prefix)
58+
setCustomIconsLoader(config.customIconsLoader, prefix)
59+
}
60+
}

0 commit comments

Comments
 (0)