From b6aceb51e5205a3fd4e0354e56266253def26ac6 Mon Sep 17 00:00:00 2001 From: Matteo Carpella Date: Wed, 12 Aug 2026 12:05:01 +0200 Subject: [PATCH 1/2] fix(head): resolve reactive input before pushing to unhead Fixes #1004 Co-Authored-By: Claude Opus 5 --- playground/pages/reactive-head.vue | 34 +++++++++++++++++++++++++++ src/runtime/composables/head.ts | 37 ++++++++++++++++++++++++++---- test/e2e/ion-head.spec.ts | 22 +++++++++++++++++- 3 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 playground/pages/reactive-head.vue diff --git a/playground/pages/reactive-head.vue b/playground/pages/reactive-head.vue new file mode 100644 index 00000000..4789f9dc --- /dev/null +++ b/playground/pages/reactive-head.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/runtime/composables/head.ts b/src/runtime/composables/head.ts index 96bcf666..026865f6 100644 --- a/src/runtime/composables/head.ts +++ b/src/runtime/composables/head.ts @@ -1,7 +1,8 @@ import { onIonViewDidEnter, onIonViewDidLeave } from '@ionic/vue' import type { ActiveHeadEntry, UseHeadInput, UseHeadOptions } from '@unhead/vue/types' import type { useHead as _useHead } from '@unhead/vue' -import { getCurrentInstance, onBeforeUnmount } from 'vue' +import { VueResolver, walkResolver } from '@unhead/vue/utils' +import { getCurrentInstance, getCurrentScope, onBeforeUnmount, watchEffect } from 'vue' import { useRoute, useRouter } from 'vue-router' import { injectHead } from '#imports' @@ -23,6 +24,18 @@ export function useHead>(obj: UseHeadInput, _?: const currentPath = (instance && useRoute().path) || '' let innerObj = obj + + /* `@unhead/vue` resolves reactive input before pushing it (see `clientUseHead`), but only in the + composable we are replacing here — the client head has no prop resolver of its own. Refs would + otherwise reach the DOM renderer unresolved, where `JSON.stringify` on a `style`/`script` + innerHTML throws `Converting circular structure to JSON` and every other prop renders the ref + instead of its value. */ + const resolveInput = (input: UseHeadInput) => walkResolver(input, VueResolver) as UseHeadInput + + /* The map is keyed by the input object identity, so it always holds the raw input — only the + value handed to unhead is resolved */ + const findActiveEntry = () => headMap.get(currentPath)?.find(headVal => headVal[0] === innerObj)?.[1] + const __returned: Omit>, '_poll'> = { dispose() { // Can just easily mutate the array instead of wasting little CPU to slice/spread it :P @@ -41,7 +54,7 @@ export function useHead>(obj: UseHeadInput, _?: if (headArrIndex === -1) return const [, headToPatch] = headArr[headArrIndex]! innerObj = newObj - headToPatch?.patch(innerObj) + headToPatch?.patch(resolveInput(innerObj)) headArr.splice(headArrIndex, 1, [innerObj, headToPatch]) headMap.set(currentPath, headArr) }, @@ -50,15 +63,29 @@ export function useHead>(obj: UseHeadInput, _?: /* Initially assign the head to the respected slots in the map because Ionic components don't unmount the way we expect them to */ if (!headMap.has(currentPath)) { - const headObj = activeHead?.push(obj) + const headObj = activeHead?.push(resolveInput(obj)) headMap.set(currentPath, [[obj, headObj]]) } else { - const headObj = activeHead?.push(obj) + const headObj = activeHead?.push(resolveInput(obj)) const metaArr = headMap.get(currentPath) || [] headMap.set(currentPath, [...metaArr, [obj, headObj]]) } + /* Keep reactive input in sync, the same way `clientUseHead` does. The entry is looked up on each + run because `onIonViewDidEnter` disposes and re-pushes it */ + if (getCurrentScope()) { + let isInitialRun = true + watchEffect(() => { + const resolved = resolveInput(innerObj) + if (isInitialRun) { + isInitialRun = false + return + } + findActiveEntry()?.patch(resolved) + }) + } + // Only use lifecycle hooks if called inside component setup if (instance) { const router = useRouter() @@ -97,7 +124,7 @@ export function useHead>(obj: UseHeadInput, _?: if (headArr) { headArr = headArr.map(([obj, head]) => { head?.dispose() - const newHead = activeHead?.push(obj) + const newHead = activeHead?.push(resolveInput(obj)) return [obj, newHead] }) headMap.set(currPath, headArr) diff --git a/test/e2e/ion-head.spec.ts b/test/e2e/ion-head.spec.ts index 2096826f..34327935 100644 --- a/test/e2e/ion-head.spec.ts +++ b/test/e2e/ion-head.spec.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url' import { setup, createPage, url } from '@nuxt/test-utils/e2e' -import { describe, it } from 'vitest' +import { describe, expect, it } from 'vitest' import type { Page } from 'playwright-core' function expectTitleToBe(page: Page, title: string) { @@ -76,4 +76,24 @@ describe('Nuxt Ionic useHead', async () => { await page.close() }) + + it('useHead should resolve reactive input on the client', { timeout: 120_000 }, async () => { + const page = await createPage() + const errors: string[] = [] + page.on('pageerror', error => errors.push(error.message)) + + await page.goto(url('/reactive-head'), { waitUntil: 'hydration' }) + await expectTitleToBe(page, 'Reactive Head - 0') + + // an unresolved ref reaches `JSON.stringify` as a `ComputedRefImpl` + await page.waitForFunction(() => document.getElementById('reactive-head-style')?.textContent?.includes('--count: 0')) + + await page.click('.reactive-head-increment') + await expectTitleToBe(page, 'Reactive Head - 1') + await page.waitForFunction(() => document.getElementById('reactive-head-style')?.textContent?.includes('--count: 1')) + + expect(errors).toEqual([]) + + await page.close() + }) }) From 6355f959f6773bb709cbe68ade734b6f025ab2b1 Mon Sep 17 00:00:00 2001 From: Matteo Carpella Date: Wed, 12 Aug 2026 12:27:15 +0200 Subject: [PATCH 2/2] fix(head): improve comments --- src/runtime/composables/head.ts | 13 ++++--------- test/e2e/ion-head.spec.ts | 1 - 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/runtime/composables/head.ts b/src/runtime/composables/head.ts index 026865f6..08e71d21 100644 --- a/src/runtime/composables/head.ts +++ b/src/runtime/composables/head.ts @@ -25,15 +25,10 @@ export function useHead>(obj: UseHeadInput, _?: let innerObj = obj - /* `@unhead/vue` resolves reactive input before pushing it (see `clientUseHead`), but only in the - composable we are replacing here — the client head has no prop resolver of its own. Refs would - otherwise reach the DOM renderer unresolved, where `JSON.stringify` on a `style`/`script` - innerHTML throws `Converting circular structure to JSON` and every other prop renders the ref - instead of its value. */ + // Reactive input has to be resolved before it reaches unhead, as `clientUseHead` does const resolveInput = (input: UseHeadInput) => walkResolver(input, VueResolver) as UseHeadInput - /* The map is keyed by the input object identity, so it always holds the raw input — only the - value handed to unhead is resolved */ + // The map keeps the raw input as its key, only what we hand to unhead is resolved const findActiveEntry = () => headMap.get(currentPath)?.find(headVal => headVal[0] === innerObj)?.[1] const __returned: Omit>, '_poll'> = { @@ -72,8 +67,8 @@ export function useHead>(obj: UseHeadInput, _?: headMap.set(currentPath, [...metaArr, [obj, headObj]]) } - /* Keep reactive input in sync, the same way `clientUseHead` does. The entry is looked up on each - run because `onIonViewDidEnter` disposes and re-pushes it */ + /* Keep the entry in sync with the input, looking it up on each run + because `onIonViewDidEnter` disposes and re-pushes it */ if (getCurrentScope()) { let isInitialRun = true watchEffect(() => { diff --git a/test/e2e/ion-head.spec.ts b/test/e2e/ion-head.spec.ts index 34327935..b62f0d38 100644 --- a/test/e2e/ion-head.spec.ts +++ b/test/e2e/ion-head.spec.ts @@ -85,7 +85,6 @@ describe('Nuxt Ionic useHead', async () => { await page.goto(url('/reactive-head'), { waitUntil: 'hydration' }) await expectTitleToBe(page, 'Reactive Head - 0') - // an unresolved ref reaches `JSON.stringify` as a `ComputedRefImpl` await page.waitForFunction(() => document.getElementById('reactive-head-style')?.textContent?.includes('--count: 0')) await page.click('.reactive-head-increment')