Skip to content

SSR: _api.setFetch($fetch.native) disables icon loading entirely — useRequestFetch() returns Nitro's event.$fetch, which has no .native #518

Description

@afuno

Versions

@nuxt/icon 2.4.0, 2.4.1 (2.3.1 unaffected)
nuxt 4.5.1
nitropack 2.13.4
@iconify/vue 5.0.1
ofetch 1.5.1

Symptom

Every <Icon> fails to render server-side. The SSR HTML contains <span class="iconify i-…"> placeholders with no data:image/svg+xml mask, and the server logs one [Icon] failed to load icon <name> per icon per render. The browser then resolves them after hydration, so the icons appear — blank until then.

No network request is made at any point. Not a failed request, not a 404 — none at all. The request is abandoned before a URL is constructed, which is what makes this hard to recognise: the usual diagnostics (check the API endpoint, check connectivity, check CSP) all come back clean.

Root cause

dist/runtime/plugin.js changed in 2.4.0. Two lines, against 2.3.1:

-import { defineNuxtPlugin, useAppConfig, useRuntimeConfig } from "#imports";
+import { defineNuxtPlugin, useAppConfig, useRequestFetch, useRuntimeConfig } from "#imports";
 ...
-    _api.setFetch($fetch.native);
+    const $fetch = useRequestFetch();
+    _api.setFetch($fetch.native);

In 2.3.1 $fetch is Nuxt's auto-imported ofetch instance on both server and client. In 2.4.x the local binding shadows it, and on the server it is a different object:

  1. useRequestFetch() returns useRequestEvent()?.$fetch || $fetchnuxt/dist/app/composables/ssr.js:37-40. During SSR the event exists, so the first branch wins.
  2. Nitro assigns that property as a plain arrow function, not an ofetch instance — nitropack/dist/runtime/internal/app.mjs:62-63:
    event.$fetch = (req, init) => fetchWithEvent(event, req, init, { fetch: $fetch });
    It carries no .native, so $fetch.native is undefined. (The ofetch instance with .native is the one assigned to globalThis.$fetch at app.mjs:110-115 — the one 2.3.1 used.)
  3. _api.setFetch(undefined) assigns fetchModule = undefined@iconify/vue/dist/iconify.mjs:669-670.
  4. send() bails at the top — iconify.mjs:746-750:
    const send = (host, params, callback) => {
      if (!fetchModule) {
        callback("abort", 424);
        return;
      }
    fetchModule(host + path) is at :770, so the abort happens before any URL is built.

Worth noting that step 3 does not merely fail to supply a fetch — it destroys a working one. @iconify/vue initialises fetchModule = detectFetch() (iconify.mjs:655-665), which picks up the global fetch and would have worked. The plugin overwrites it with undefined.

Why the client is unaffected

useRequestFetch() returns the global ofetch instance in the browser (ssr.js:38, if (import.meta.client) return $fetch), and that does carry .native. Only the server branch is broken, which is why the icons appear after hydration and the bug reads as a rendering delay rather than a failure.

Reproduction

  1. Nuxt 4.5 app, SSR on, @nuxt/icon 2.4.1, no icon config.
  2. One <Icon name="fa6-solid:moon" /> on a page.
  3. nuxt build && node .output/server/index.mjs, request the page.

Expected: the server-rendered HTML contains a data:image/svg+xml mask for the icon.
Actual: it contains an empty <span class="iconify i-fa6-solid:moon">, the server logs [Icon] failed to load icon fa6-solid:moon, and no outbound request is made.

Downgrading to 2.3.1 with no other change fixes it.

The fix is not to restore .native

ofetch defines it as a raw passthrough — ofetch/dist/shared/ofetch.CWycOUEr.mjs:336:

$fetch.native = (...args) => fetch(...args);

It discards ofetch's decoration and any request context. So #514 ("fix: avoid relying on global fetch") could not have delivered request-scoped fetching even if event.$fetch had carried .native: taking .native off any instance strips exactly the scoping the change set out to preserve, in 2.3.1 no less than in 2.4.x.

Making event.$fetch expose .native would restore the previous behaviour but not achieve the stated goal. If request-scoped fetching is wanted, _api.setFetch needs a function that actually routes through the event — for example wrapping useRequestFetch() itself rather than reaching for a property that is defined to bypass it.

Workaround

Bundle the icons so the fetch path is never reached:

export default defineNuxtConfig({
  icon: {
    clientBundle: {
      icons: ['fa6-solid:moon', /* … */],
      scan: true,
    },
  },
})

loadIcon() calls initClientBundle(_addIcon) and returns early if _getIcon(name) resolves, before _loadIcon is reached — dist/runtime/components/shared.js:6-13. This runs on the server as well as the client, so it is independent of serverBundle mode. It also removes the runtime dependency on the Iconify CDN, which is worth having regardless of this bug.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions