diff --git a/src/runtime/components/css-cache.ts b/src/runtime/components/css-cache.ts new file mode 100644 index 00000000..2aaf1a32 --- /dev/null +++ b/src/runtime/components/css-cache.ts @@ -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() + +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 +} diff --git a/src/runtime/components/css.ts b/src/runtime/components/css.ts index db121b2b..f149eca2 100644 --- a/src/runtime/components/css.ts +++ b/src/runtime/components/css.ts @@ -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' @@ -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} }` } diff --git a/test/css-cache.test.ts b/test/css-cache.test.ts new file mode 100644 index 00000000..80b4f04a --- /dev/null +++ b/test/css-cache.test.ts @@ -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() + return { getIconCSS: vi.fn(original.getIconCSS) } +}) + +const { getIconCSS: uncached } = await vi.importActual('@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: '', 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 = '' + 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) +})