Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions playground/pages/reactive-head.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed, ref } from 'vue'

const count = ref(0)

const title = computed(() => `Reactive Head - ${count.value}`)
const styleContent = computed(() => `.reactive-head-probe { --count: ${count.value} }`)

useHead({
title,
style: [{ innerHTML: styleContent, id: 'reactive-head-style' }],
})
</script>

<template>
<ion-page>
<ion-header>
<ion-toolbar>
<ion-title>Reactive head</ion-title>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true">
<ion-button
class="reactive-head-increment"
@click="count++"
>
Increment
</ion-button>
<ion-label class="reactive-head-count">
{{ count }}
</ion-label>
</ion-content>
</ion-page>
</template>
32 changes: 27 additions & 5 deletions src/runtime/composables/head.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -23,6 +24,13 @@ export function useHead<T extends Record<string, any>>(obj: UseHeadInput<T>, _?:
const currentPath = (instance && useRoute().path) || ''

let innerObj = obj

// Reactive input has to be resolved before it reaches unhead, as `clientUseHead` does
const resolveInput = (input: UseHeadInput<T>) => walkResolver(input, VueResolver) as UseHeadInput<T>

// 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<ActiveHeadEntry<UseHeadInput<T>>, '_poll'> = {
dispose() {
// Can just easily mutate the array instead of wasting little CPU to slice/spread it :P
Expand All @@ -41,7 +49,7 @@ export function useHead<T extends Record<string, any>>(obj: UseHeadInput<T>, _?:
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)
},
Expand All @@ -50,15 +58,29 @@ export function useHead<T extends Record<string, any>>(obj: UseHeadInput<T>, _?:
/* 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 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(() => {
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()
Expand Down Expand Up @@ -97,7 +119,7 @@ export function useHead<T extends Record<string, any>>(obj: UseHeadInput<T>, _?:
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)
Expand Down
21 changes: 20 additions & 1 deletion test/e2e/ion-head.spec.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -76,4 +76,23 @@ 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')

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()
})
})