From e89cb3649e63679a6f1564fe8c3818ad0fe29fde Mon Sep 17 00:00:00 2001 From: Thrasher <19455623+thrashxr@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:27:48 +0300 Subject: [PATCH 1/4] fix(plugin): chunk custom icon loader queries to avoid 431/414 --- src/runtime/custom-icon-loader.ts | 72 +++++++++++++++++++++ src/runtime/plugin.ts | 19 +++--- test/custom-icon-loader.test.ts | 101 ++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 src/runtime/custom-icon-loader.ts create mode 100644 test/custom-icon-loader.test.ts diff --git a/src/runtime/custom-icon-loader.ts b/src/runtime/custom-icon-loader.ts new file mode 100644 index 00000000..2e8a4e15 --- /dev/null +++ b/src/runtime/custom-icon-loader.ts @@ -0,0 +1,72 @@ +import type { IconifyJSON } from '@iconify/types' + +/** + * Max length of the `icons=` query value. Keeps GET URLs under common + * reverse-proxy / server limits that surface as 431 / 414 when a custom + * collection asks for hundreds or thousands of names in one request. + * Midpoint of a safe ~2–4k character budget for the joined name list. + */ +export const ICONS_QUERY_BUDGET = 3072 + +/** + * Split icon names so that `names.join(',')` stays within `maxBytes`. + * A single name longer than the budget still forms its own chunk. + */ +export function chunkIconsByQueryBudget(icons: string[], maxBytes = ICONS_QUERY_BUDGET): string[][] { + if (!icons.length) + return [] + + const chunks: string[][] = [] + let current: string[] = [] + let currentLen = 0 + + for (const name of icons) { + const added = current.length === 0 ? name.length : name.length + 1 + if (current.length > 0 && currentLen + added > maxBytes) { + chunks.push(current) + current = [name] + currentLen = name.length + } + else { + current.push(name) + currentLen += added + } + } + + if (current.length) + chunks.push(current) + + return chunks +} + +/** + * Merge chunk responses: `prefix` from the first item, `icons` and `aliases` unioned. + */ +export function mergeIconifyJSONChunks(chunks: IconifyJSON[], prefix: string): IconifyJSON { + return chunks.reduce((acc, item) => ({ + prefix: acc.prefix || item.prefix, + icons: { ...acc.icons, ...item.icons }, + aliases: { ...acc.aliases, ...item.aliases }, + }), { prefix, icons: {}, aliases: {} }) +} + +/** + * Fetch custom icons in query-budget chunks and merge. Throws on invalid data + * so the caller can map any failure to `null` (same as the previous single-request path). + */ +export async function loadCustomIconsChunked( + icons: string[], + prefix: string, + fetchChunk: (names: string[]) => Promise, + maxBytes = ICONS_QUERY_BUDGET, +): Promise { + const nameChunks = chunkIconsByQueryBudget(icons, maxBytes) + const results = await Promise.all(nameChunks.map(chunk => fetchChunk(chunk))) + + for (const data of results) { + if (!data || data.prefix !== prefix || !data.icons) + throw new Error('Invalid data' + JSON.stringify(data)) + } + + return mergeIconifyJSONChunks(results, prefix) +} diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index 7ce48af6..db178e2e 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -1,6 +1,7 @@ import { addAPIProvider, _api, setCustomIconsLoader } from '@iconify/vue' import type { IconifyJSON } from '@iconify/types' import type { NuxtIconRuntimeOptions } from '../types' +import { loadCustomIconsChunked } from './custom-icon-loader' import { getSideProvider } from './provider' import { defineNuxtPlugin, useAppConfig, useRequestFetch, useRuntimeConfig } from '#imports' @@ -40,15 +41,15 @@ export default defineNuxtPlugin({ async function customIconLoader(icons: string[], prefix: string): Promise { try { - const data = await requestFetch(resources[0] + '/' + prefix + '.json', { - query: { - icons: icons.join(','), - }, - }) as IconifyJSON - // Simple data validation - if (!data || data.prefix !== prefix || !data.icons) - throw new Error('Invalid data' + JSON.stringify(data)) - return data as IconifyJSON + return await loadCustomIconsChunked( + icons, + prefix, + names => requestFetch(resources[0] + '/' + prefix + '.json', { + query: { + icons: names.join(','), + }, + }) as Promise, + ) } catch (e) { console.error('Failed to load custom icons', e) diff --git a/test/custom-icon-loader.test.ts b/test/custom-icon-loader.test.ts new file mode 100644 index 00000000..5fcb518c --- /dev/null +++ b/test/custom-icon-loader.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import type { IconifyJSON } from '@iconify/types' +import { + ICONS_QUERY_BUDGET, + chunkIconsByQueryBudget, + loadCustomIconsChunked, + mergeIconifyJSONChunks, +} from '../src/runtime/custom-icon-loader' + +describe('chunkIconsByQueryBudget', () => { + it('returns empty for an empty list', () => { + expect(chunkIconsByQueryBudget([])).toEqual([]) + }) + + it('keeps a short list in one chunk', () => { + expect(chunkIconsByQueryBudget(['a', 'b', 'c'], 100)).toEqual([['a', 'b', 'c']]) + }) + + it('splits when the joined query would exceed the budget', () => { + // "aaaa,bbbb" is 9 chars; budget 8 → each name alone + expect(chunkIconsByQueryBudget(['aaaa', 'bbbb', 'cccc'], 8)).toEqual([ + ['aaaa'], + ['bbbb'], + ['cccc'], + ]) + }) + + it('packs as many names as fit under the budget', () => { + // "aa,bb" = 5; adding ",cc" would be 8 > 6 → second chunk + expect(chunkIconsByQueryBudget(['aa', 'bb', 'cc'], 6)).toEqual([ + ['aa', 'bb'], + ['cc'], + ]) + }) + + it('puts an oversized single name in its own chunk', () => { + expect(chunkIconsByQueryBudget(['abcdefghij'], 5)).toEqual([['abcdefghij']]) + }) + + it('keeps the default budget join under the limit', () => { + const names = Array.from({ length: 1500 }, (_, i) => `icon-${String(i).padStart(4, '0')}`) + const chunks = chunkIconsByQueryBudget(names) + expect(chunks.length).toBeGreaterThan(1) + for (const chunk of chunks) + expect(chunk.join(',').length).toBeLessThanOrEqual(ICONS_QUERY_BUDGET) + expect(chunks.flat()).toEqual(names) + }) +}) + +describe('mergeIconifyJSONChunks', () => { + it('merges icons and aliases and keeps the first prefix', () => { + const a: IconifyJSON = { + prefix: 'fa', + icons: { home: { body: '' } }, + aliases: { house: 'home' }, + } + const b: IconifyJSON = { + prefix: 'fa', + icons: { user: { body: '' } }, + aliases: { person: 'user' }, + } + expect(mergeIconifyJSONChunks([a, b], 'fa')).toEqual({ + prefix: 'fa', + icons: { home: { body: '' }, user: { body: '' } }, + aliases: { house: 'home', person: 'user' }, + }) + }) +}) + +describe('loadCustomIconsChunked', () => { + it('fetches each chunk and merges icons and aliases', async () => { + const names = Array.from({ length: 20 }, (_, i) => `n${i}`) + const fetchChunk = vi.fn(async (chunk: string[]) => ({ + prefix: 'c', + icons: Object.fromEntries(chunk.map(n => [n, { body: `<${n}/>` }])), + aliases: Object.fromEntries(chunk.map(n => [`a-${n}`, n])), + } satisfies IconifyJSON)) + + const result = await loadCustomIconsChunked(names, 'c', fetchChunk, 30) + + expect(fetchChunk.mock.calls.length).toBeGreaterThan(1) + for (const [chunk] of fetchChunk.mock.calls) + expect((chunk as string[]).join(',').length).toBeLessThanOrEqual(30) + expect(Object.keys(result.icons).sort()).toEqual([...names].sort()) + expect(Object.keys(result.aliases || {}).sort()).toEqual(names.map(n => `a-${n}`).sort()) + expect(result.prefix).toBe('c') + }) + + it('throws when any chunk returns invalid data (caller maps to null)', async () => { + await expect(loadCustomIconsChunked( + ['a', 'b', 'c'], + 'c', + async (chunk) => { + if (chunk.includes('b')) + return { prefix: 'wrong', icons: { b: { body: '' } } } + return { prefix: 'c', icons: Object.fromEntries(chunk.map(n => [n, { body: '' }])) } + }, + 3, + )).rejects.toThrow(/Invalid data/) + }) +}) From 84d488b7c44177d4c78edbfa165e79cc26c03c13 Mon Sep 17 00:00:00 2001 From: Thrasher <19455623+thrashxr@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:31:33 +0300 Subject: [PATCH 2/4] test: use iconify alias objects in custom loader tests --- test/custom-icon-loader.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/custom-icon-loader.test.ts b/test/custom-icon-loader.test.ts index 5fcb518c..1faf9018 100644 --- a/test/custom-icon-loader.test.ts +++ b/test/custom-icon-loader.test.ts @@ -52,17 +52,17 @@ describe('mergeIconifyJSONChunks', () => { const a: IconifyJSON = { prefix: 'fa', icons: { home: { body: '' } }, - aliases: { house: 'home' }, + aliases: { house: { parent: 'home' } }, } const b: IconifyJSON = { prefix: 'fa', icons: { user: { body: '' } }, - aliases: { person: 'user' }, + aliases: { person: { parent: 'user' } }, } expect(mergeIconifyJSONChunks([a, b], 'fa')).toEqual({ prefix: 'fa', icons: { home: { body: '' }, user: { body: '' } }, - aliases: { house: 'home', person: 'user' }, + aliases: { house: { parent: 'home' }, person: { parent: 'user' } }, }) }) }) @@ -73,7 +73,7 @@ describe('loadCustomIconsChunked', () => { const fetchChunk = vi.fn(async (chunk: string[]) => ({ prefix: 'c', icons: Object.fromEntries(chunk.map(n => [n, { body: `<${n}/>` }])), - aliases: Object.fromEntries(chunk.map(n => [`a-${n}`, n])), + aliases: Object.fromEntries(chunk.map(n => [`a-${n}`, { parent: n }])), } satisfies IconifyJSON)) const result = await loadCustomIconsChunked(names, 'c', fetchChunk, 30) From 4988ecb72b1f58edfc3997b0771fd01713e89fc0 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 24 Oct 2026 21:25:08 +0000 Subject: [PATCH 3/4] fix: simplify custom icon chunking, keep collection dimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline the chunking in customIconLoader and drop the helper module. Merge responses with their collection-level properties (width, height, ...) so icons keep their viewBox, and ignore failed chunks instead of failing the whole batch. Amp-Thread-ID: https://ampcode.com/threads/T-01a16fb6-6c46-701b-a070-0bda354c7dbb Co-authored-by: Sébastien Chopin --- src/runtime/custom-icon-loader.ts | 72 --------------------- src/runtime/plugin.ts | 46 ++++++++++---- test/custom-icon-loader.test.ts | 101 ------------------------------ 3 files changed, 35 insertions(+), 184 deletions(-) delete mode 100644 src/runtime/custom-icon-loader.ts delete mode 100644 test/custom-icon-loader.test.ts diff --git a/src/runtime/custom-icon-loader.ts b/src/runtime/custom-icon-loader.ts deleted file mode 100644 index 2e8a4e15..00000000 --- a/src/runtime/custom-icon-loader.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { IconifyJSON } from '@iconify/types' - -/** - * Max length of the `icons=` query value. Keeps GET URLs under common - * reverse-proxy / server limits that surface as 431 / 414 when a custom - * collection asks for hundreds or thousands of names in one request. - * Midpoint of a safe ~2–4k character budget for the joined name list. - */ -export const ICONS_QUERY_BUDGET = 3072 - -/** - * Split icon names so that `names.join(',')` stays within `maxBytes`. - * A single name longer than the budget still forms its own chunk. - */ -export function chunkIconsByQueryBudget(icons: string[], maxBytes = ICONS_QUERY_BUDGET): string[][] { - if (!icons.length) - return [] - - const chunks: string[][] = [] - let current: string[] = [] - let currentLen = 0 - - for (const name of icons) { - const added = current.length === 0 ? name.length : name.length + 1 - if (current.length > 0 && currentLen + added > maxBytes) { - chunks.push(current) - current = [name] - currentLen = name.length - } - else { - current.push(name) - currentLen += added - } - } - - if (current.length) - chunks.push(current) - - return chunks -} - -/** - * Merge chunk responses: `prefix` from the first item, `icons` and `aliases` unioned. - */ -export function mergeIconifyJSONChunks(chunks: IconifyJSON[], prefix: string): IconifyJSON { - return chunks.reduce((acc, item) => ({ - prefix: acc.prefix || item.prefix, - icons: { ...acc.icons, ...item.icons }, - aliases: { ...acc.aliases, ...item.aliases }, - }), { prefix, icons: {}, aliases: {} }) -} - -/** - * Fetch custom icons in query-budget chunks and merge. Throws on invalid data - * so the caller can map any failure to `null` (same as the previous single-request path). - */ -export async function loadCustomIconsChunked( - icons: string[], - prefix: string, - fetchChunk: (names: string[]) => Promise, - maxBytes = ICONS_QUERY_BUDGET, -): Promise { - const nameChunks = chunkIconsByQueryBudget(icons, maxBytes) - const results = await Promise.all(nameChunks.map(chunk => fetchChunk(chunk))) - - for (const data of results) { - if (!data || data.prefix !== prefix || !data.icons) - throw new Error('Invalid data' + JSON.stringify(data)) - } - - return mergeIconifyJSONChunks(results, prefix) -} diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index db178e2e..095eeea5 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -1,7 +1,6 @@ import { addAPIProvider, _api, setCustomIconsLoader } from '@iconify/vue' import type { IconifyJSON } from '@iconify/types' import type { NuxtIconRuntimeOptions } from '../types' -import { loadCustomIconsChunked } from './custom-icon-loader' import { getSideProvider } from './provider' import { defineNuxtPlugin, useAppConfig, useRequestFetch, useRuntimeConfig } from '#imports' @@ -39,17 +38,17 @@ export default defineNuxtPlugin({ resources.push(options.iconifyApiEndpoint!) } - async function customIconLoader(icons: string[], prefix: string): Promise { + async function loadCustomIconsChunk(icons: string[], prefix: string): Promise { try { - return await loadCustomIconsChunked( - icons, - prefix, - names => requestFetch(resources[0] + '/' + prefix + '.json', { - query: { - icons: names.join(','), - }, - }) as Promise, - ) + const data = await requestFetch(resources[0] + '/' + prefix + '.json', { + query: { + icons: icons.join(','), + }, + }) as IconifyJSON + // Simple data validation + if (!data || data.prefix !== prefix || !data.icons) + throw new Error('Invalid data' + JSON.stringify(data)) + return data as IconifyJSON } catch (e) { console.error('Failed to load custom icons', e) @@ -57,6 +56,31 @@ export default defineNuxtPlugin({ } } + async function customIconLoader(icons: string[], prefix: string): Promise { + // Split large lists into several requests to stay under URL length limits (431 / 414) + const chunks: string[][] = [[]] + let length = 0 + for (const name of icons) { + if (length + name.length > 2000 && chunks.at(-1)!.length) { + chunks.push([]) + length = 0 + } + chunks.at(-1)!.push(name) + length += name.length + 1 + } + const results = (await Promise.all(chunks.map(chunk => loadCustomIconsChunk(chunk, prefix)))) + .filter((data): data is IconifyJSON => !!data) + if (!results.length) + return null + // Keep collection-level properties (width, height, ...) from the responses + return results.reduce((acc, data) => ({ + ...acc, + ...data, + icons: { ...acc.icons, ...data.icons }, + aliases: { ...acc.aliases, ...data.aliases }, + })) + } + addAPIProvider('', { resources }) // Register custom collections handlers diff --git a/test/custom-icon-loader.test.ts b/test/custom-icon-loader.test.ts deleted file mode 100644 index 1faf9018..00000000 --- a/test/custom-icon-loader.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { IconifyJSON } from '@iconify/types' -import { - ICONS_QUERY_BUDGET, - chunkIconsByQueryBudget, - loadCustomIconsChunked, - mergeIconifyJSONChunks, -} from '../src/runtime/custom-icon-loader' - -describe('chunkIconsByQueryBudget', () => { - it('returns empty for an empty list', () => { - expect(chunkIconsByQueryBudget([])).toEqual([]) - }) - - it('keeps a short list in one chunk', () => { - expect(chunkIconsByQueryBudget(['a', 'b', 'c'], 100)).toEqual([['a', 'b', 'c']]) - }) - - it('splits when the joined query would exceed the budget', () => { - // "aaaa,bbbb" is 9 chars; budget 8 → each name alone - expect(chunkIconsByQueryBudget(['aaaa', 'bbbb', 'cccc'], 8)).toEqual([ - ['aaaa'], - ['bbbb'], - ['cccc'], - ]) - }) - - it('packs as many names as fit under the budget', () => { - // "aa,bb" = 5; adding ",cc" would be 8 > 6 → second chunk - expect(chunkIconsByQueryBudget(['aa', 'bb', 'cc'], 6)).toEqual([ - ['aa', 'bb'], - ['cc'], - ]) - }) - - it('puts an oversized single name in its own chunk', () => { - expect(chunkIconsByQueryBudget(['abcdefghij'], 5)).toEqual([['abcdefghij']]) - }) - - it('keeps the default budget join under the limit', () => { - const names = Array.from({ length: 1500 }, (_, i) => `icon-${String(i).padStart(4, '0')}`) - const chunks = chunkIconsByQueryBudget(names) - expect(chunks.length).toBeGreaterThan(1) - for (const chunk of chunks) - expect(chunk.join(',').length).toBeLessThanOrEqual(ICONS_QUERY_BUDGET) - expect(chunks.flat()).toEqual(names) - }) -}) - -describe('mergeIconifyJSONChunks', () => { - it('merges icons and aliases and keeps the first prefix', () => { - const a: IconifyJSON = { - prefix: 'fa', - icons: { home: { body: '' } }, - aliases: { house: { parent: 'home' } }, - } - const b: IconifyJSON = { - prefix: 'fa', - icons: { user: { body: '' } }, - aliases: { person: { parent: 'user' } }, - } - expect(mergeIconifyJSONChunks([a, b], 'fa')).toEqual({ - prefix: 'fa', - icons: { home: { body: '' }, user: { body: '' } }, - aliases: { house: { parent: 'home' }, person: { parent: 'user' } }, - }) - }) -}) - -describe('loadCustomIconsChunked', () => { - it('fetches each chunk and merges icons and aliases', async () => { - const names = Array.from({ length: 20 }, (_, i) => `n${i}`) - const fetchChunk = vi.fn(async (chunk: string[]) => ({ - prefix: 'c', - icons: Object.fromEntries(chunk.map(n => [n, { body: `<${n}/>` }])), - aliases: Object.fromEntries(chunk.map(n => [`a-${n}`, { parent: n }])), - } satisfies IconifyJSON)) - - const result = await loadCustomIconsChunked(names, 'c', fetchChunk, 30) - - expect(fetchChunk.mock.calls.length).toBeGreaterThan(1) - for (const [chunk] of fetchChunk.mock.calls) - expect((chunk as string[]).join(',').length).toBeLessThanOrEqual(30) - expect(Object.keys(result.icons).sort()).toEqual([...names].sort()) - expect(Object.keys(result.aliases || {}).sort()).toEqual(names.map(n => `a-${n}`).sort()) - expect(result.prefix).toBe('c') - }) - - it('throws when any chunk returns invalid data (caller maps to null)', async () => { - await expect(loadCustomIconsChunked( - ['a', 'b', 'c'], - 'c', - async (chunk) => { - if (chunk.includes('b')) - return { prefix: 'wrong', icons: { b: { body: '' } } } - return { prefix: 'c', icons: Object.fromEntries(chunk.map(n => [n, { body: '' }])) } - }, - 3, - )).rejects.toThrow(/Invalid data/) - }) -}) From 58a3316aede7349182261127bbebf524a2a3df2d Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 24 Oct 2026 21:35:35 +0000 Subject: [PATCH 4/4] fix: measure encoded icon names when chunking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amp-Thread-ID: https://ampcode.com/threads/T-01a16fb6-6c46-701b-a070-0bda354c7dbb Co-authored-by: Sébastien Chopin --- src/runtime/plugin.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index 095eeea5..97ac9c24 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -61,12 +61,13 @@ export default defineNuxtPlugin({ const chunks: string[][] = [[]] let length = 0 for (const name of icons) { - if (length + name.length > 2000 && chunks.at(-1)!.length) { + const nameLength = encodeURIComponent(name).length + if (length + nameLength > 2000 && chunks.at(-1)!.length) { chunks.push([]) length = 0 } chunks.at(-1)!.push(name) - length += name.length + 1 + length += nameLength + 1 } const results = (await Promise.all(chunks.map(chunk => loadCustomIconsChunk(chunk, prefix)))) .filter((data): data is IconifyJSON => !!data)