From d80de8d781d3804f23fe4b7475c452daf21f62a3 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 23 Oct 2026 22:37:28 +0000 Subject: [PATCH 1/4] perf: sort icon collections once when resolving names --- src/runtime/components/resolve-name.ts | 34 ++++++++++++++++ src/runtime/components/shared.ts | 20 +-------- test/resolve-name.test.ts | 56 ++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 18 deletions(-) create mode 100644 src/runtime/components/resolve-name.ts create mode 100644 test/resolve-name.test.ts diff --git a/src/runtime/components/resolve-name.ts b/src/runtime/components/resolve-name.ts new file mode 100644 index 00000000..e364f608 --- /dev/null +++ b/src/runtime/components/resolve-name.ts @@ -0,0 +1,34 @@ +import type { NuxtIconRuntimeOptions } from '../../types' + +const HYPHEN = 45 + +const sortedCollectionsCache = new WeakMap() + +export function getSortedCollections(collections: string[] | null | undefined): string[] { + if (!collections) + return [] + let sorted = sortedCollectionsCache.get(collections) + if (!sorted || sorted.length !== collections.length) { + sorted = [...collections].sort((a, b) => b.length - a.length) + sortedCollectionsCache.set(collections, sorted) + } + return sorted +} + +export function resolveName(name: string, options: Pick): string { + const bare = name.startsWith(options.cssSelectorPrefix) + ? name.slice(options.cssSelectorPrefix.length) + : name + const resolved = options.aliases?.[bare] || bare + + // Disambiguate collection + // e.g. `simple-icons-github` -> `simple-icons:github` + if (!resolved.includes(':')) { + const collection = getSortedCollections(options.collections) + .find(c => resolved.charCodeAt(c.length) === HYPHEN && resolved.startsWith(c)) + return collection + ? collection + ':' + resolved.slice(collection.length + 1) + : resolved + } + return resolved +} diff --git a/src/runtime/components/shared.ts b/src/runtime/components/shared.ts index 3b27a05c..4e21a8a2 100644 --- a/src/runtime/components/shared.ts +++ b/src/runtime/components/shared.ts @@ -4,6 +4,7 @@ import type { IconifyIcon } from '@iconify/types' import type { NuxtIconRuntimeOptions } from '../../types' import { useAppConfig } from '#imports' import { init as initClientBundle } from '#build/nuxt-icon-client-bundle' +import { resolveName } from './resolve-name' export { initClientBundle } @@ -47,25 +48,8 @@ export async function loadIcon(name: string, timeout: number): Promise string) { const options = useAppConfig().icon as NuxtIconRuntimeOptions - const collections = (options.collections || []).sort((a, b) => b.length - a.length) - return computed(() => { - const name = getName() - const bare = name.startsWith(options.cssSelectorPrefix) - ? name.slice(options.cssSelectorPrefix.length) - : name - const resolved = options.aliases?.[bare] || bare - - // Disambiguate collection - // e.g. `simple-icons-github` -> `simple-icons:github` - if (!resolved.includes(':')) { - const collection = collections.find(c => resolved.startsWith(c + '-')) - return collection - ? collection + ':' + resolved.slice(collection.length + 1) - : resolved - } - return resolved - }) + return computed(() => resolveName(getName(), options)) } export function resolveCustomizeFn( diff --git a/test/resolve-name.test.ts b/test/resolve-name.test.ts new file mode 100644 index 00000000..28fad088 --- /dev/null +++ b/test/resolve-name.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { getSortedCollections, resolveName } from '../src/runtime/components/resolve-name' + +function createOptions(collections: string[] | null, aliases: Record = {}) { + return { cssSelectorPrefix: 'i-', aliases, collections } +} + +describe('resolveName', () => { + it('keeps names that already contain a collection separator', () => { + expect(resolveName('i-simple-icons:github', createOptions(['simple-icons']))).toBe('simple-icons:github') + }) + + it('resolves a bare name against the longest matching collection', () => { + const options = createOptions(['simple', 'simple-icons', 'si']) + expect(resolveName('i-simple-icons-github', options)).toBe('simple-icons:github') + expect(resolveName('simple-icons-github', options)).toBe('simple-icons:github') + expect(resolveName('i-simple-foo', options)).toBe('simple:foo') + expect(resolveName('i-si-foo', options)).toBe('si:foo') + }) + + it('requires a hyphen after the collection name', () => { + const options = createOptions(['simple', 'si']) + expect(resolveName('i-simpleicons', options)).toBe('simpleicons') + expect(resolveName('i-si', options)).toBe('si') + }) + + it('resolves aliases before matching collections', () => { + expect(resolveName('i-github', createOptions(['simple-icons'], { github: 'simple-icons-github' }))).toBe('simple-icons:github') + }) + + it('keeps bare names without a matching collection', () => { + expect(resolveName('i-unknown-icon', createOptions(['simple-icons']))).toBe('unknown-icon') + expect(resolveName('i-unknown-icon', createOptions(null))).toBe('unknown-icon') + }) + + it('does not reorder the configured collections', () => { + const collections = ['simple', 'simple-icons', 'si'] + resolveName('i-simple-icons-github', createOptions(collections)) + expect(collections).toEqual(['simple', 'simple-icons', 'si']) + }) + + it('picks up collections added after the first resolution', () => { + const collections = ['simple'] + const options = createOptions(collections) + expect(resolveName('i-simple-icons-github', options)).toBe('simple:icons-github') + collections.push('simple-icons') + expect(resolveName('i-simple-icons-github', options)).toBe('simple-icons:github') + }) +}) + +describe('getSortedCollections', () => { + it('sorts each collections array once', () => { + const collections = ['simple', 'simple-icons'] + expect(getSortedCollections(collections)).toBe(getSortedCollections(collections)) + }) +}) From 78ca138317cbc9e6338062f1f1b73abb86336ad4 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 24 Oct 2026 16:31:40 +0000 Subject: [PATCH 2/4] fix: re-sort collections updated in place on the client --- src/runtime/components/resolve-name.ts | 25 +++++++++++++++++++------ test/resolve-name.test.ts | 8 ++++++++ vitest.config.ts | 3 +++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/runtime/components/resolve-name.ts b/src/runtime/components/resolve-name.ts index e364f608..d33eecca 100644 --- a/src/runtime/components/resolve-name.ts +++ b/src/runtime/components/resolve-name.ts @@ -1,17 +1,30 @@ +import { toRaw } from 'vue' import type { NuxtIconRuntimeOptions } from '../../types' const HYPHEN = 45 -const sortedCollectionsCache = new WeakMap() +const sortedCollectionsCache = new WeakMap() + +function isSameList(a: string[], b: string[]) { + if (a.length !== b.length) + return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) + return false + } + return true +} export function getSortedCollections(collections: string[] | null | undefined): string[] { if (!collections) return [] - let sorted = sortedCollectionsCache.get(collections) - if (!sorted || sorted.length !== collections.length) { - sorted = [...collections].sort((a, b) => b.length - a.length) - sortedCollectionsCache.set(collections, sorted) - } + const source = toRaw(collections) + const cached = sortedCollectionsCache.get(source) + // App config is cloned per request on the server, but updated in place on the client (HMR, `updateAppConfig`) + if (cached && (import.meta.client ? isSameList(cached.source, source) : cached.source.length === source.length)) + return cached.sorted + const sorted = [...source].sort((a, b) => b.length - a.length) + sortedCollectionsCache.set(source, { source: [...source], sorted }) return sorted } diff --git a/test/resolve-name.test.ts b/test/resolve-name.test.ts index 28fad088..7d7431ac 100644 --- a/test/resolve-name.test.ts +++ b/test/resolve-name.test.ts @@ -1,3 +1,4 @@ +import { reactive } from 'vue' import { describe, expect, it } from 'vitest' import { getSortedCollections, resolveName } from '../src/runtime/components/resolve-name' @@ -46,6 +47,13 @@ describe('resolveName', () => { collections.push('simple-icons') expect(resolveName('i-simple-icons-github', options)).toBe('simple-icons:github') }) + + it('picks up reactive collections replaced in place', () => { + const options = reactive(createOptions(['simple', 'foo'])) + expect(resolveName('i-simple-icons-github', options)).toBe('simple:icons-github') + options.collections![1] = 'simple-icons' + expect(resolveName('i-simple-icons-github', options)).toBe('simple-icons:github') + }) }) describe('getSortedCollections', () => { diff --git a/vitest.config.ts b/vitest.config.ts index 37fb2828..58825fa9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ + define: { + 'import.meta.client': 'true', + }, test: { exclude: ['playgrounds/**', 'node_modules/**'], }, From 93e421b94e70b13094f02a07749734627d2db398 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 24 Oct 2026 16:57:59 +0000 Subject: [PATCH 3/4] fix: update resolved icon names when collections change on the client --- src/runtime/components/resolve-name.ts | 31 +++++++++----------------- test/resolve-name.test.ts | 30 +++++++++++++++---------- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/src/runtime/components/resolve-name.ts b/src/runtime/components/resolve-name.ts index d33eecca..d502aefc 100644 --- a/src/runtime/components/resolve-name.ts +++ b/src/runtime/components/resolve-name.ts @@ -1,31 +1,22 @@ -import { toRaw } from 'vue' +import { computed, toRaw } from 'vue' import type { NuxtIconRuntimeOptions } from '../../types' const HYPHEN = 45 -const sortedCollectionsCache = new WeakMap() - -function isSameList(a: string[], b: string[]) { - if (a.length !== b.length) - return false - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) - return false - } - return true -} +const sortedCollectionsCache = new WeakMap() export function getSortedCollections(collections: string[] | null | undefined): string[] { if (!collections) return [] - const source = toRaw(collections) - const cached = sortedCollectionsCache.get(source) - // App config is cloned per request on the server, but updated in place on the client (HMR, `updateAppConfig`) - if (cached && (import.meta.client ? isSameList(cached.source, source) : cached.source.length === source.length)) - return cached.sorted - const sorted = [...source].sort((a, b) => b.length - a.length) - sortedCollectionsCache.set(source, { source: [...source], sorted }) - return sorted + const key = toRaw(collections) + let sorted = sortedCollectionsCache.get(key) + if (!sorted) { + const sort = () => [...collections].sort((a, b) => b.length - a.length) + // App config is cloned per request on the server, but reactive and updated in place on the client (HMR, `updateAppConfig`) + sorted = import.meta.client ? computed(sort) : { value: sort() } + sortedCollectionsCache.set(key, sorted) + } + return sorted.value } export function resolveName(name: string, options: Pick): string { diff --git a/test/resolve-name.test.ts b/test/resolve-name.test.ts index 7d7431ac..971ba9fc 100644 --- a/test/resolve-name.test.ts +++ b/test/resolve-name.test.ts @@ -1,4 +1,4 @@ -import { reactive } from 'vue' +import { computed, reactive } from 'vue' import { describe, expect, it } from 'vitest' import { getSortedCollections, resolveName } from '../src/runtime/components/resolve-name' @@ -40,19 +40,25 @@ describe('resolveName', () => { expect(collections).toEqual(['simple', 'simple-icons', 'si']) }) - it('picks up collections added after the first resolution', () => { - const collections = ['simple'] - const options = createOptions(collections) - expect(resolveName('i-simple-icons-github', options)).toBe('simple:icons-github') - collections.push('simple-icons') - expect(resolveName('i-simple-icons-github', options)).toBe('simple-icons:github') - }) - - it('picks up reactive collections replaced in place', () => { + it('updates computed names when reactive collections change in place', () => { const options = reactive(createOptions(['simple', 'foo'])) - expect(resolveName('i-simple-icons-github', options)).toBe('simple:icons-github') + const first = computed(() => resolveName('i-simple-icons-github', options)) + const second = computed(() => resolveName('i-simple-icons-github', options)) + expect(first.value).toBe('simple:icons-github') + expect(second.value).toBe('simple:icons-github') + options.collections![1] = 'simple-icons' - expect(resolveName('i-simple-icons-github', options)).toBe('simple-icons:github') + expect(first.value).toBe('simple-icons:github') + expect(second.value).toBe('simple-icons:github') + }) + + it('updates computed names when reactive collections grow', () => { + const options = reactive(createOptions(['simple'])) + const name = computed(() => resolveName('i-simple-icons-github', options)) + expect(name.value).toBe('simple:icons-github') + + options.collections!.push('simple-icons') + expect(name.value).toBe('simple-icons:github') }) }) From 533260aef9066ec028dacc04770f4eaa06229cb1 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 24 Oct 2026 18:52:12 +0000 Subject: [PATCH 4/4] fix: track collections through `reactive()` on the client --- src/runtime/components/resolve-name.ts | 6 +++--- test/resolve-name.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/runtime/components/resolve-name.ts b/src/runtime/components/resolve-name.ts index d502aefc..84295387 100644 --- a/src/runtime/components/resolve-name.ts +++ b/src/runtime/components/resolve-name.ts @@ -1,4 +1,4 @@ -import { computed, toRaw } from 'vue' +import { computed, reactive, toRaw } from 'vue' import type { NuxtIconRuntimeOptions } from '../../types' const HYPHEN = 45 @@ -11,9 +11,9 @@ export function getSortedCollections(collections: string[] | null | undefined): const key = toRaw(collections) let sorted = sortedCollectionsCache.get(key) if (!sorted) { - const sort = () => [...collections].sort((a, b) => b.length - a.length) + const sort = (source: string[]) => [...source].sort((a, b) => b.length - a.length) // App config is cloned per request on the server, but reactive and updated in place on the client (HMR, `updateAppConfig`) - sorted = import.meta.client ? computed(sort) : { value: sort() } + sorted = import.meta.client ? computed(() => sort(reactive(key))) : { value: sort(key) } sortedCollectionsCache.set(key, sorted) } return sorted.value diff --git a/test/resolve-name.test.ts b/test/resolve-name.test.ts index 971ba9fc..61898fbd 100644 --- a/test/resolve-name.test.ts +++ b/test/resolve-name.test.ts @@ -52,6 +52,18 @@ describe('resolveName', () => { expect(second.value).toBe('simple-icons:github') }) + it('updates computed names when collections first resolved as a plain array change', () => { + const collections = ['simple', 'foo'] + expect(resolveName('i-simple-icons-github', createOptions(collections))).toBe('simple:icons-github') + + const options = reactive(createOptions(collections)) + const name = computed(() => resolveName('i-simple-icons-github', options)) + expect(name.value).toBe('simple:icons-github') + + options.collections![1] = 'simple-icons' + expect(name.value).toBe('simple-icons:github') + }) + it('updates computed names when reactive collections grow', () => { const options = reactive(createOptions(['simple'])) const name = computed(() => resolveName('i-simple-icons-github', options))