From feff320e9300fdd5f4baf9c9a8388ea6d9caa9f7 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 23 Oct 2026 22:37:14 +0000 Subject: [PATCH 1/2] perf: memoise generated icon CSS across requests --- src/runtime/components/css-cache.ts | 23 ++++++++++ src/runtime/components/css.ts | 8 +--- test/css-cache.test.ts | 70 +++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 src/runtime/components/css-cache.ts create mode 100644 test/css-cache.test.ts diff --git a/src/runtime/components/css-cache.ts b/src/runtime/components/css-cache.ts new file mode 100644 index 00000000..1cc47564 --- /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 => 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 (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..712763d6 --- /dev/null +++ b/test/css-cache.test.ts @@ -0,0 +1,70 @@ +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('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) +}) From ef92abd60026ccd0865b9a1e759b8aa63b6a5a65 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 24 Oct 2026 15:50:54 +0000 Subject: [PATCH 2/2] fix: check own keys and avoid clearing on selector replacement --- src/runtime/components/css-cache.ts | 4 ++-- test/css-cache.test.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/runtime/components/css-cache.ts b/src/runtime/components/css-cache.ts index 1cc47564..2aaf1a32 100644 --- a/src/runtime/components/css-cache.ts +++ b/src/runtime/components/css-cache.ts @@ -6,7 +6,7 @@ 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 => a[key] === b[key]) + 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) { @@ -16,7 +16,7 @@ export function getCachedIconCSS(icon: IconifyIcon, iconSelector: string, custom if (cached && isSameIcon(cached.icon, icon)) return cached.css const css = getIconCSS(icon, { iconSelector, format: 'compressed' }) - if (cache.size >= MAX_SIZE) + if (!cached && cache.size >= MAX_SIZE) cache.clear() cache.set(iconSelector, { icon: { ...icon }, css }) return css diff --git a/test/css-cache.test.ts b/test/css-cache.test.ts index 712763d6..80b4f04a 100644 --- a/test/css-cache.test.ts +++ b/test/css-cache.test.ts @@ -41,6 +41,11 @@ it('regenerates CSS when the icon data changes', () => { 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') @@ -68,3 +73,11 @@ it('clears the cache once it holds 1000 selectors', () => { 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) +})