Skip to content

Commit c4f7935

Browse files
fix: don't hard-fail on unresolvable hook-contributed client bundle icons (#504)
1 parent 23ef020 commit c4f7935

3 files changed

Lines changed: 66 additions & 7 deletions

File tree

src/bundle-client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function registerClientBundle(
1818
} = ctx.options.clientBundle || {}
1919

2020
// TODO: find a granular way to cache this
21-
const { collections, count, failed } = await ctx.loadClientBundleCollections()
21+
const { collections, count, failed, dropped } = await ctx.loadClientBundleCollections()
2222

2323
if (cacheSize === count && cacheData && cacheVersion === ctx.clientBundleVersion) {
2424
return cacheData
@@ -32,6 +32,10 @@ export function registerClientBundle(
3232
logger.warn(msg)
3333
}
3434

35+
if (dropped.length) {
36+
logger.warn(`Nuxt Icon could not resolve these icons for the client bundle, falling back to runtime loading:\n${dropped.map(f => ' - ' + f).join('\n')}`)
37+
}
38+
3539
if (!collections.length)
3640
return 'export function init() {}'
3741

src/context.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export class NuxtIconModuleContext {
143143
)
144144
}
145145

146-
async loadClientBundleCollections(): Promise<{ collections: IconifyJSON[], count: number, failed: string[] }> {
146+
async loadClientBundleCollections(): Promise<{ collections: IconifyJSON[], count: number, failed: string[], dropped: string[] }> {
147147
const {
148148
includeCustomCollections = this.options.provider !== 'server',
149149
scan = false,
@@ -173,6 +173,7 @@ export class NuxtIconModuleContext {
173173
count: 0,
174174
collections: [],
175175
failed: [],
176+
dropped: [],
176177
}
177178
}
178179

@@ -182,6 +183,7 @@ export class NuxtIconModuleContext {
182183
const { loadCollectionFromFS } = await import('@iconify/utils/lib/loader/fs')
183184

184185
const failed: string[] = []
186+
const dropped: string[] = []
185187
let count = 0
186188

187189
const customCollectionNames = new Set(customCollections.map(c => c.prefix))
@@ -239,18 +241,33 @@ export class NuxtIconModuleContext {
239241
data = getIconData(collection, name)
240242

241243
if (!data) {
242-
// We don't warn for scanned icons, because the extraction can have false positives
243-
if (!this.scannedIcons.has(icon) || userIcons.has(icon)) {
244+
// Only icons the user explicitly listed in `clientBundle.icons` hard-fail
245+
// the build. Scanned and hook-contributed icons are best-effort: drop them
246+
// from the bundle and fall back to runtime loading.
247+
if (userIcons.has(icon)) {
244248
failed.push(icon)
245249
}
250+
// We don't warn for scanned icons, because the extraction can have false
251+
// positives; hook-contributed icons are surfaced so module authors get a
252+
// heads-up that something couldn't be bundled.
253+
else if (!this.scannedIcons.has(icon)) {
254+
dropped.push(icon)
255+
}
246256
}
247257
else {
248258
addIcon(prefix, name, data)
249259
}
250260
}
251261
catch (e) {
252262
console.error(e)
253-
failed.push(icon)
263+
// Mirror the best-effort handling above: only explicit user icons hard-fail,
264+
// scanned/hook-contributed icons fall back to runtime loading.
265+
if (userIcons.has(icon)) {
266+
failed.push(icon)
267+
}
268+
else if (!this.scannedIcons.has(icon)) {
269+
dropped.push(icon)
270+
}
254271
}
255272
}))
256273

@@ -273,6 +290,7 @@ export class NuxtIconModuleContext {
273290
collections: [...collections.values()],
274291
count,
275292
failed,
293+
dropped,
276294
}
277295
}
278296
}

test/client-bundle.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,14 @@ function installCollection(dir: string, prefix: string, icon: string) {
2727
}))
2828
}
2929

30-
function createContext(rootDir: string, workspaceDir: string, icons: string[]) {
30+
function createContext(rootDir: string, workspaceDir: string, icons: string[], hookIcons: string[] = []) {
3131
const nuxt = {
3232
options: { rootDir, workspaceDir },
33-
callHook: async () => {},
33+
// Mimic a module contributing icons through the `icon:clientBundleIcons` hook
34+
callHook: async (_name: string, set: Set<string>) => {
35+
for (const icon of hookIcons)
36+
set.add(icon)
37+
},
3438
}
3539
return new NuxtIconModuleContext(nuxt as never, {
3640
clientBundle: { icons },
@@ -75,3 +79,36 @@ it('falls back to workspaceDir when the collection is not under rootDir', async
7579
expect(result.count).toBe(1)
7680
expect(result.collections.find(c => c.prefix === 'nuxt-icon-test-ws')?.icons.bar).toBeTruthy()
7781
})
82+
83+
it('does not hard-fail when a hook-contributed icon cannot be resolved', async () => {
84+
// A module adds an icon from a collection that is not installed. It must be
85+
// best-effort: omitted from the bundle (so it falls back to runtime loading)
86+
// and never added to `failed` (which would throw in a production build).
87+
const context = createContext(appDir(), appDir(), ['nuxt-icon-test:foo'], ['not-installed:missing'])
88+
const result = await context.loadClientBundleCollections()
89+
90+
expect(result.failed).toEqual([])
91+
expect(result.dropped).toEqual(['not-installed:missing'])
92+
expect(result.count).toBe(1)
93+
expect(result.collections.find(c => c.prefix === 'not-installed')).toBeUndefined()
94+
expect(result.collections.find(c => c.prefix === 'nuxt-icon-test')?.icons.foo).toBeTruthy()
95+
})
96+
97+
it('does not hard-fail when a hook-contributed icon name does not exist in an installed collection', async () => {
98+
// The collection is installed but the specific icon name does not exist
99+
// (e.g. version skew). Still best-effort, not a hard failure.
100+
const context = createContext(appDir(), appDir(), [], ['nuxt-icon-test:does-not-exist'])
101+
const result = await context.loadClientBundleCollections()
102+
103+
expect(result.failed).toEqual([])
104+
expect(result.dropped).toEqual(['nuxt-icon-test:does-not-exist'])
105+
expect(result.count).toBe(0)
106+
})
107+
108+
it('still hard-fails when an icon explicitly listed in clientBundle.icons cannot be resolved', async () => {
109+
const context = createContext(appDir(), appDir(), ['not-installed:missing'])
110+
const result = await context.loadClientBundleCollections()
111+
112+
expect(result.failed).toEqual(['not-installed:missing'])
113+
expect(result.dropped).toEqual([])
114+
})

0 commit comments

Comments
 (0)