Skip to content

feat(unplugin): pre-bundle used icons into the Vue/Vite build#6635

Open
benjamincanac wants to merge 17 commits into
v4from
feat/icon-bundle-vue
Open

feat(unplugin): pre-bundle used icons into the Vue/Vite build#6635
benjamincanac wants to merge 17 commits into
v4from
feat/icon-bundle-vue

Conversation

@benjamincanac

@benjamincanac benjamincanac commented Jun 25, 2026

Copy link
Copy Markdown
Member

Resolves #5242 and supersedes #5894.

Context

On the Vue/Vite side, the icons Nuxt UI uses were never bundled, so @iconify/vue fetched them from the Iconify API at runtime. That broke offline use, caused the SSR "icon appears after hydration" flash, and added network round-trips. This is the Vue equivalent of what #6633 wires up for Nuxt through @nuxt/icon's client bundle.

Unlike Nuxt, pure Vue has no server to serve icons from, so embedding them in the client build is the only way to render them offline and during SSR.

What this does

The @nuxt/ui/vite sub-plugin (src/plugins/icons.ts) delegates the bundling to @nuxt/icon's framework-agnostic primitives (@nuxt/icon/utils, from nuxt/icon#506) rather than reimplementing it, so this bumps the @nuxt/icon dependency to ^2.3.1. It uses the three-tier resolveBundleIcons model:

  • Nuxt UI's own default icons are contributed on the soft extraIcons tier, so a missing collection degrades to runtime loading instead of failing the build (Nuxt UI hard-depends on no collection).
  • The user's clientBundle.icons go through the icons tier (an unresolved one hard-fails the build, as in @nuxt/icon), and scan reuses @nuxt/icon's IconUsageScanner.
  • Collections are resolved from the Vite config.root (the fix: resolve client bundle collections from rootDir/workspaceDir icon#502 lesson), so workspace/monorepo builds work.

generateClientBundleCode produces a virtual:nuxt-ui-icons module exporting init(addIcon); the runtime plugin runtime/vue/plugins/icons calls it with @iconify/vue's addIcon on both server and client, so icons render synchronously during SSR and fully offline while keeping the i-lucide-* syntax. Bundling runs in dev too, not only in vite build.

Per @nuxt/icon's guidance for library authors, this is the intended integration path (the standalone NuxtIconBundle plugin is for end-user apps and can't softly inject a library's own defaults).

Options

Mirrors @nuxt/icon's clientBundle (icons, scan, sizeLimitKb). Bundling is on by default for Nuxt UI's own icons; you extend, scan, or disable it through icon.clientBundle:

import ui from '@nuxt/ui/vite'

export default defineConfig({
  plugins: [
    ui({
      icon: {
        clientBundle: {
          // bundle extra icons explicitly
          icons: ['lucide:heart', 'simple-icons:github'],
          // or scan your source and bundle the icons you actually use
          scan: true
          // set `clientBundle: false` to opt out entirely
        }
      }
    })
  ]
})
  • icons accepts i-{collection}-{name} or {collection}:{name} (use the colon form for multi-word collections like material-symbols:menu).
  • scan is opt-in (off by default). It scans your source (.vue/.jsx/.tsx/.md/...) for icon usages and bundles the ones from installed collections, so icons used in your own components are bundled too.

Following the same decision as #6633, this does not add @iconify-json/lucide as a hard dependency. Icons are bundled only when their collection is installed; anything else falls back to runtime loading.

How to test

  1. Install a collection in your Vue app, for example @iconify-json/lucide.
  2. Build or SSR-render a page using a Nuxt UI component with an icon: it appears in the output/server-rendered HTML with no request to api.iconify.design.
  3. Add icon: { clientBundle: { scan: true } } and the icons used in your own components get bundled too.

Notes

benjamincanac and others added 3 commits June 25, 2026 11:27
Add the icons Nuxt UI uses (chevrons, loading spinner, close, etc.) to `@nuxt/icon`'s client bundle via the `icon:clientBundleIcons` hook so they're embedded at build time instead of fetched at runtime, removing the first-paint flash.

Icons are only added when their collection data is installed (resolved from `rootDir`/`workspaceDir` to match `@nuxt/icon`), so missing collections fall back to runtime loading instead of failing the build.

Resolves #6295
Temporary: relies on nuxt/icon#502 (client bundle resolves collections from rootDir/workspaceDir). Revert to a normal version once it's released.
Embed the icons Nuxt UI uses at build time via a new `@nuxt/ui/vite` sub-plugin so they render during SSR and work fully offline with no runtime Iconify API fetch, mirroring what #6633 does on the Nuxt side. Adds an `icon.clientBundle` option to bundle your own icons on top of the defaults.

Co-authored-by: Typed SIGTERM <145281501+typed-sigterm@users.noreply.github.com>
@benjamincanac benjamincanac changed the title feat: pre-bundle used icons into the Vue/Vite build feat(unplugin): pre-bundle used icons into the Vue/Vite build Jun 25, 2026
Base automatically changed from feat/pre-bundle-icons to v4 June 30, 2026 12:39
# Conflicts:
#	package.json
#	pnpm-lock.yaml
#	src/module.ts
#	src/utils/icons.ts
#	test/utils/icons.spec.ts
@pkg-pr-new

pkg-pr-new Bot commented Jun 30, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/@nuxt/ui@6635

commit: 08afb74

A virtual module can't resolve a bare specifier like `@iconify/vue` in a non-workspace install, which broke standalone Vue builds. Export only the icon data from `virtual:nuxt-ui-icons` and do the `addIcon` registration in a real runtime plugin that owns the `@iconify/vue` import.
@benjamincanac benjamincanac marked this pull request as ready for review June 30, 2026 13:34
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds build-time icon bundling for Nuxt UI in Vue/Vite mode. IconsPlugin generates a virtual:nuxt-ui-icons module from installed @iconify-json/* packages, parseIconName normalizes icon identifiers, a runtime Vue plugin registers bundled icons with addIcon, and NuxtUIOptions.icon gains a clientBundle option for explicit icons, scanning, or disabling bundling. Dependencies and docs were updated for local icon datasets and SSR behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: pre-bundling used icons into the Vue/Vite build.
Docstring Coverage ✅ Passed Docstring coverage is 81.82% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description clearly matches the changeset, explaining the new Vue/Vite icon bundling plugin, SSR/offline behavior, and clientBundle options.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/icon-bundle-vue

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
docs/content/docs/1.getting-started/6.integrations/1.icons/2.vue.md (1)

102-104: 📐 Maintainability & Code Quality | 🔵 Trivial

Verify the anchor target for this note.

The note advises {collection}:{name} form for dash-containing collections. Ensure the vite.config.ts example above this note also works with the i- prefix syntax (e.g., i-material-symbols-menu) since users may try that first. Consider adding a brief mention that clientBundle.icons requires the explicit {collection}:{name} form, not the i- prefixed class-style syntax.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/content/docs/1.getting-started/6.integrations/1.icons/2.vue.md` around
lines 102 - 104, The note currently only points users to the {collection}:{name}
format, but the nearby vite.config.ts example should also explicitly clarify
that the i- prefix syntax (for example, i-material-symbols-menu) is not
supported by clientBundle.icons. Update the documentation around the icons
example and the note so it explains that collections with dashes must use the
explicit {collection}:{name} form, and add a brief mention that users should
install the matching `@iconify-json/`{collection_name} package for each referenced
collection.
test/utils/icon-bundle.spec.ts (1)

8-9: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add one fixture test for a non-root cwd.

These assertions only exercise loadIconsData(..., process.cwd()), so the new workspace/monorepo behavior is still unguarded. A focused test that passes a nested project root would protect the main config.root claim in this PR.

Also applies to: 17-17, 25-25

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/utils/icon-bundle.spec.ts` around lines 8 - 9, Add a focused fixture
test in icon-bundle.spec.ts that exercises loadIconsData with a non-root cwd
instead of only process.cwd(). Use the existing loadIconsData test cases as a
guide, but pass a nested workspace/project root fixture so the monorepo behavior
is covered and the config.root behavior is asserted. Keep the test colocated
with the current loadIconsData coverage so the new cwd scenario protects the
same path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@docs/content/docs/1.getting-started/6.integrations/1.icons/2.vue.md`:
- Around line 102-104: The note currently only points users to the
{collection}:{name} format, but the nearby vite.config.ts example should also
explicitly clarify that the i- prefix syntax (for example,
i-material-symbols-menu) is not supported by clientBundle.icons. Update the
documentation around the icons example and the note so it explains that
collections with dashes must use the explicit {collection}:{name} form, and add
a brief mention that users should install the matching
`@iconify-json/`{collection_name} package for each referenced collection.

In `@test/utils/icon-bundle.spec.ts`:
- Around line 8-9: Add a focused fixture test in icon-bundle.spec.ts that
exercises loadIconsData with a non-root cwd instead of only process.cwd(). Use
the existing loadIconsData test cases as a guide, but pass a nested
workspace/project root fixture so the monorepo behavior is covered and the
config.root behavior is asserted. Keep the test colocated with the current
loadIconsData coverage so the new cwd scenario protects the same path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: db2805e5-1041-4547-82cb-97c265c8cb47

📥 Commits

Reviewing files that changed from the base of the PR and between 8d46034 and cb856a6.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (12)
  • docs/content/docs/1.getting-started/6.integrations/1.icons/2.vue.md
  • docs/content/docs/1.getting-started/6.integrations/6.ssr.md
  • package.json
  • playgrounds/vue/package.json
  • src/plugins/icons.ts
  • src/plugins/plugins.ts
  • src/runtime/types/icons.d.ts
  • src/runtime/vue/plugins/icons.ts
  • src/unplugin.ts
  • src/utils/icons.ts
  • test/utils/icon-bundle.spec.ts
  • test/utils/icons.spec.ts

Scans the project source for icon usages from installed collections and bundles them, mirroring `@nuxt/icon`'s `clientBundle.scan`, so icons used in your own components render offline too. Opt-in, off by default.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/plugins/icons.ts`:
- Around line 115-118: Escape each collection name before building the
alternation in createMatchRegex so filesystem-derived names cannot inject regex
metacharacters; update the regex construction to use escaped values while
keeping the existing sort and matching behavior intact.
- Around line 141-146: The icon scan in extractUsedIcons currently reads every
matched file at once via Promise.all(files.map(...)), which can exhaust file
descriptors in large workspaces. Update the file-processing loop in
src/plugins/icons.ts to read files sequentially or with a small concurrency
limit, while preserving the existing readFile, extractUsedIcons, and names.add
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7aeb6603-3319-450e-90bf-a4bb096a9546

📥 Commits

Reviewing files that changed from the base of the PR and between 78e5e67 and 406f40e.

📒 Files selected for processing (3)
  • docs/content/docs/1.getting-started/6.integrations/1.icons/2.vue.md
  • src/plugins/icons.ts
  • src/unplugin.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/unplugin.ts
  • docs/content/docs/1.getting-started/6.integrations/1.icons/2.vue.md

Comment thread src/plugins/icons.ts Outdated
Comment thread src/plugins/icons.ts Outdated
…options

Type `icon.clientBundle` as `@nuxt/icon`'s `ClientBundleOptions` (minus `includeCustomCollections`, which has no Vue equivalent) instead of a hand-rolled subset, and implement the previously-missing options: `sizeLimitKb` (build-size guard, default 256), and `scan.ignoreCollections` / `scan.additionalCollections`.
@socket-security

socket-security Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​nuxt/​icon@​2.3.19810010096100

View full report

Instead of a hand-rolled scanner/loader, use the canonical primitives from @nuxt/icon 2.3.1 (nuxt/icon#506): `resolveBundleIcons` (with Nuxt UI's defaults on the soft `extraIcons` tier so a missing collection degrades to runtime instead of failing the build), `IconUsageScanner` for `scan`, and `generateClientBundleCode` (which emits `init(addIcon)` and enforces `sizeLimitKb`). The runtime plugin registers via `init(addIcon)` on Nuxt UI's own `@iconify/vue`.
- Parse user `clientBundle.icons` against @nuxt/icon's known collection list so multi-word dash forms (e.g. `i-material-symbols-menu`) resolve instead of mis-splitting and hard-failing the build.
- Read scanned files in bounded batches to avoid EMFILE on large workspaces (@nuxt/icon's scanFiles reads all at once).
- Don't cache a rejected generation promise, so a fix is picked up without a dev-server restart.
- Drop the now-unused `@iconify/utils` dependency and root `@iconify-json/lucide` devDependency (leftovers from the pre-delegation implementation and removed SSR test).
Match `@nuxt/icon`'s vite plugin: an unresolved `clientBundle.icons` entry throws on build but only warns in dev, so a typo doesn't crash the dev server (it falls back to runtime there). This removes the reason for the rejected-promise cache workaround, so `load()` goes back to a plain memoized `generate()`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v4 #4488

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How to bundle default Nuxt UI icons (proper offline icons) with Vite?

1 participant