Skip to content

bug(colors plugin): SPA mode crashes on Nuxt >= 4.5.1 — injectHead(...).hooks.hookOnce is not a function (unhead v3) #301

Description

@IgorShevchik

Two problems reported for SPA mode (ssr: false) with @bitrix24/b24ui-nuxt@2.9.0. They look independent of each other:

  1. App initialization crashes inside the b24ui colors plugin after upgrading Nuxt to 4.5.1.
  2. @nuxt/content / MDC renders nothing in SPA — also reproduces on nuxt@4.4.8, and still happens on 4.5.1 after the crashing line is commented out.

1. colors plugin crashes in SPA on Nuxt >= 4.5.1

Error

[NUXT_E1005] Error caught during app initialization.

╰▶ fix: Check your plugins, app:created, and app:beforeMount hooks for unhandled errors.

injectHead(...).hooks.hookOnce is not a function

  /node_modules/.pnpm/@bitrix24+b24ui-nuxt@2.9.0_.../node_modules/@bitrix24/b24ui-nuxt/dist/runtime/plugins/colors.js

The offending call is in the published @bitrix24/b24ui-nuxt@2.9.0, dist/runtime/plugins/colors.js:34
(the dev-server stack frame pointed at 36/38 — Vite's transform shifts the line numbers):

injectHead().hooks.hookOnce("dom:rendered", removeTemporaryColorsStyle);

Root cause — Nuxt 4.5.1 bumps unhead v2 -> v3

Nuxt unhead / @unhead/vue
4.4.8 ^2.1.15
4.5.1 ^3.2.3

unhead v2 built head.hooks with hookable's createHooks(), producing a full Hookable, which implements hookOnce():

// unhead@2.1.16 — dist/shared/unhead.CaI5ZD4O.mjs
import { createHooks } from 'hookable'
const hooks = createHooks()

unhead v3 builds them from HookableCore instead:

// unhead@3.2.3 — dist/shared/unhead.Bm4Y6XQI.mjs
import { HookableCore } from 'hookable'

function createHooks(hooks) {
  const instance = new HookableCore()
  for (const key in hooks || {}) {
    instance.hook(key, hooks[key])
  }
  return instance
}

And HookableCore (hookable@6) exposes only three methods — no hookOnce():

declare class HookableCore<...> {
  hook<NameT>(name: NameT, fn: InferCallback<HooksT, NameT>): () => void
  removeHook<NameT>(name: NameT, function_: InferCallback<HooksT, NameT>): void
  callHook<NameT>(name: NameT, ...args): Promise<any> | void
}

So head.hooks.hookOnce is undefined on unhead v3 -> TypeError.

Why SPA only

The call sits in the SPA-only branch of the plugin:

if (import.meta.client && nuxtApp.isHydrating && !nuxtApp.payload.serverRendered) {
  // ...
  injectHead().hooks.hookOnce("dom:rendered", removeTemporaryColorsStyle);
}

With SSR enabled, payload.serverRendered is true, the branch never runs, and nothing breaks. That is why this only shows up with ssr: false.

Status — fixed on main, but not released

main already carries a version-agnostic fix in src/runtime/plugins/colors.ts:

// `hookOnce` is only available on unhead v2's `Hookable`. In v3 `hooks` is a `HookableCore`
// that exposes `hook` only, so self-unhook to keep the once semantics across both versions.
const head = injectHead()
const unhook = head.hooks?.hook('dom:rendered', () => {
  removeTemporaryColorsStyle()
  unhook?.()
})

This is correct on both majors, since HookableCore.hook() returns an unhook function (() => void).

However, the latest version published to npm is 2.9.0, and it still ships hookOnce — so every user on the current release hits this. A release is needed to ship the fix.

Workaround until a release is cut

Pin Nuxt to 4.4.8 (keeps unhead on v2, where hookOnce exists).

Reproduction

  1. Nuxt app with ssr: false
  2. @bitrix24/b24ui-nuxt@2.9.0
  3. Upgrade nuxt to 4.5.1
  4. Load the app -> NUXT_E1005 on init

2. @nuxt/content / MDC renders nothing in SPA

Reported separately, needs reproduction — it does not look related to the colors plugin:

  • With @nuxt/content installed on nuxt@4.4.8 — where problem 1 does not occur, because unhead v2 still has hookOnce — MDC does not render at all.
  • On nuxt@4.5.1, after commenting out the crashing line in the plugin, MDC still renders nothing in SPA.

Since it reproduces on both Nuxt versions and survives neutralizing the plugin, the two problems should be treated as distinct.

To check during analysis

  • @nuxt/content v3 is SQLite-backed. With ssr: false the content database has to be served to the client (WASM SQLite connector). Worth confirming the app's content config first, before digging into b24ui — this may be an app-side setup issue rather than a b24ui bug. (Hypothesis, not verified.)
  • b24ui registers Prose / content components only when the module is detected — see src/module.ts:231-246:
    if (options.prose || options.mdc || options.content || hasNuxtModule('@nuxtjs/mdc') || hasNuxtModule('@nuxt/content')) {
      // Prose* components
    }
    if (options.content || hasNuxtModule('@nuxt/content')) {
      // B24* content components
    }
    Confirm the components are actually registered in the failing app; if module detection fails, forcing b24ui: { mdc: true, content: true } in nuxt.config is a quick way to test.
  • Narrow down where it breaks: does the content query return no documents, or does the query succeed and ContentRenderer output nothing?

Environment

  • @bitrix24/b24ui-nuxt 2.9.0
  • nuxt 4.5.1 (crash) / 4.4.8 (MDC)
  • SPA mode, ssr: false
  • pnpm

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions