Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/runtime/components/css-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getIconCSS } from '@iconify/utils/lib/css/icon'
import type { IconifyIcon } from '@iconify/types'

const MAX_SIZE = 1000
const cache = new Map<string, { icon: IconifyIcon, css: string }>()

function isSameIcon(a: IconifyIcon, b: IconifyIcon) {
const keys = Object.keys(a) as (keyof IconifyIcon)[]
return keys.length === Object.keys(b).length && keys.every(key => Object.hasOwn(b, key) && a[key] === b[key])
}

export function getCachedIconCSS(icon: IconifyIcon, iconSelector: string, customise?: (content: string) => string) {
if (customise)
return getIconCSS(icon, { iconSelector, format: 'compressed', customise })
const cached = cache.get(iconSelector)
if (cached && isSameIcon(cached.icon, icon))
return cached.css
const css = getIconCSS(icon, { iconSelector, format: 'compressed' })
if (!cached && cache.size >= MAX_SIZE)
cache.clear()
cache.set(iconSelector, { icon: { ...icon }, css })
return css
}
8 changes: 2 additions & 6 deletions src/runtime/components/css.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { addIcon, getIcon as _getIcon } from '@iconify/vue'
import { computed, watch, h, defineComponent } from 'vue'
import { getIconCSS } from '@iconify/utils/lib/css/icon'
import type { PropType } from 'vue'
import type { IconifyIcon } from '@iconify/types'
import type { NuxtIconRuntimeOptions, NuxtIconRuntimeServerOptions } from '../../types'
import type { IconifyIconCustomizeCallback } from './shared'
import { hash } from 'ohash'
import { getCachedIconCSS } from './css-cache'
import { loadIcon, resolveCustomizeFn } from './shared'
import { useAppConfig, useNuxtApp, useHead, useRuntimeConfig, onServerPrefetch } from '#imports'

Expand Down Expand Up @@ -113,11 +113,7 @@ export const NuxtIconCss = /* @__PURE__ */ defineComponent({
iconSelector = `:where(${iconSelector})`
}

const css = getIconCSS(icon, {
iconSelector,
format: 'compressed',
customise: resolveCustomizeFn(props.customize, options.customize),
})
const css = getCachedIconCSS(icon, iconSelector, resolveCustomizeFn(props.customize, options.customize))
if (options.cssLayer && withLayer) {
return `@layer ${options.cssLayer} { ${css} }`
}
Expand Down
83 changes: 83 additions & 0 deletions test/css-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { beforeEach, expect, it, vi } from 'vitest'
import type { IconifyIcon } from '@iconify/types'

vi.mock('@iconify/utils/lib/css/icon', async (importOriginal) => {
const original = await importOriginal<typeof import('@iconify/utils/lib/css/icon')>()
return { getIconCSS: vi.fn(original.getIconCSS) }
})

const { getIconCSS: uncached } = await vi.importActual<typeof import('@iconify/utils/lib/css/icon')>('@iconify/utils/lib/css/icon')

let getIconCSS: typeof uncached
let getCachedIconCSS: typeof import('../src/runtime/components/css-cache').getCachedIconCSS

beforeEach(async () => {
vi.resetModules()
getIconCSS = (await import('@iconify/utils/lib/css/icon')).getIconCSS
getCachedIconCSS = (await import('../src/runtime/components/css-cache')).getCachedIconCSS
vi.mocked(getIconCSS).mockClear()
})

const icon: IconifyIcon = { body: '<path fill="currentColor" d="M0 0h24v24H0z"/>', width: 24, height: 24 }

it('returns the same CSS as `getIconCSS`', () => {
const expected = uncached(icon, { iconSelector: '.i-a', format: 'compressed' })
expect(getCachedIconCSS(icon, '.i-a')).toBe(expected)
expect(getCachedIconCSS(icon, '.i-a')).toBe(expected)
expect(getIconCSS).toHaveBeenCalledTimes(1)
})

it('generates CSS per selector', () => {
expect(getCachedIconCSS(icon, '.i-a')).toContain('.i-a{')
expect(getCachedIconCSS(icon, ':where(.i-a)')).toContain(':where(.i-a){')
expect(getIconCSS).toHaveBeenCalledTimes(2)
})

it('regenerates CSS when the icon data changes', () => {
const css = getCachedIconCSS(icon, '.i-a')
expect(getCachedIconCSS({ ...icon, width: 16 }, '.i-a')).not.toBe(css)
expect(getCachedIconCSS({ ...icon, hFlip: true }, '.i-a')).not.toBe(css)
expect(getCachedIconCSS({ ...icon }, '.i-a')).toBe(css)
expect(getIconCSS).toHaveBeenCalledTimes(4)
})

it('distinguishes a missing field from one set to `undefined`', () => {
const css = getCachedIconCSS({ body: icon.body, width: undefined }, '.i-a')
expect(getCachedIconCSS({ body: icon.body, height: 24 }, '.i-a')).not.toBe(css)
})

it('is not affected by mutating the icon after caching', () => {
const data = { ...icon }
const css = getCachedIconCSS(data, '.i-a')
data.body = '<path d="M1 1"/>'
expect(getCachedIconCSS(data, '.i-a')).not.toBe(css)
})

it('does not cache CSS generated with `customise`', () => {
const plain = getCachedIconCSS(icon, '.i-a')
const customise = (body: string) => body.replace('currentColor', 'red')
const customised = getCachedIconCSS(icon, '.i-a', customise)
expect(customised).toBe(uncached(icon, { iconSelector: '.i-a', format: 'compressed', customise }))
expect(customised).not.toBe(plain)
expect(getCachedIconCSS(icon, '.i-a')).toBe(plain)
expect(getIconCSS).toHaveBeenCalledTimes(2)
})

it('clears the cache once it holds 1000 selectors', () => {
for (let i = 0; i < 1000; i++)
getCachedIconCSS(icon, `.i-${i}`)
getCachedIconCSS(icon, '.i-0')
expect(getIconCSS).toHaveBeenCalledTimes(1000)

getCachedIconCSS(icon, '.i-overflow')
getCachedIconCSS(icon, '.i-0')
expect(getIconCSS).toHaveBeenCalledTimes(1002)
})

it('does not clear the cache when replacing an existing selector at capacity', () => {
for (let i = 0; i < 1000; i++)
getCachedIconCSS(icon, `.i-${i}`)
getCachedIconCSS({ ...icon, width: 16 }, '.i-0')
getCachedIconCSS(icon, '.i-1')
expect(getIconCSS).toHaveBeenCalledTimes(1001)
})
Loading