unhead
unhead copied to clipboard
[v2] Calling useHead in the client after SSR no longer updates the tags in the head but instead removes existing
Environment
- Operating System: Darwin
- Node Version: v22.14.0
- Nuxt Version: 3.16.1
- CLI Version: 3.23.1
- Nitro Version: 2.11.7
- Package Manager: [email protected]
- Builder: -
- User Config: -
- Runtime Modules: -
- Build Modules: -
Reproduction
https://stackblitz.com/edit/github-pfsubbgy?file=app.vue
Describe the bug
Before unhead v2, the following composable would reactively update the tags inserted in the <head> when the styles: ComputedRef<string> changed in the calling component:
export default function useCustomStyles(styles: ComputedRef<string>, id?: string) {
const componentId = id || useId()
const computedStyles = computed((): string => {
if (!styles.value || String(styles.value || '').trim().length === 0) {
return ''
}
// wrapString just combines the values into valid CSS
return wrapString(styles.value, `#${componentId} {`, '}')
})
useHead({
style: [
computedStyles.value ? {
key: `custom-styles-${componentId}`,
id: `custom-styles-${componentId}`,
innerHTML: computedStyles.value,
tagPosition: 'head',
tagPriority: 2000,
} : {},
],
})
return {
componentId,
}
}
// Usage in a component:
const { componentId } = useCustomStyles(computed(() => props.styles), useAttrs().id as string)
Since v2, when the styles param is changed in the consuming component, the custom-styles-* entry in the head is removed (reactively); however, it is not replaced with an updated version of the tag.
Additional context
No response
Logs
Hi, a reproduction would be handy. Looking at the code I'm not exactly sure how v1 was correctly handling this, there's no way for unhead to track the reactivity side effects because you only pass the resolved value.
I'd rewrite it like so
export default function useCustomStyles(styles: ComputedRef<string>, id?: string) {
const componentId = id || useId()
useHead({
style: [
() => {
if (!styles.value || String(styles.value || '').trim().length === 0) {
return undefined
}
// wrapString just combines the values into valid CSS
return {
key: `custom-styles-${componentId}`,
id: `custom-styles-${componentId}`,
innerHTML: wrapString(styles.value, `#${componentId} {`, '}'),
tagPosition: 'head',
tagPriority: 2000,
}
}
],
})
return {
componentId,
}
}
Also you may consider using textContent over innerHTML if it doesn't cause issue, you'll be safer from XSS if the source is untrusted.
The code I have in the original post works in v2 when the page is initially rendered via SSR.
It's hard to show a full reproduction as the issue arises when something happens in the client that causes styles prop to change (via postMessage from outside the window via iframe parent 😬). I will try to put something together that adds a tag in the client on button click and see if I can reproduce.
The composable is called inside a MDC component, and when it re-renders in the client, the existing tag in the head is correctly removed (as the useId() changes when the component re-mounts, meaning the key changes).
If useHead is called in the client after the page is already rendered, should it actually inject a new tag into in the head?
Also you may consider using
textContentoverinnerHTMLif it doesn't cause issue, you'll be safer from XSS if the source is untrusted.
My actual implementation has a parseCustomCss utility wrapping the content to sanitize the content 😄
The only time Unhead will remove a tag is once the client renders it, so the only likely thing that is happening is:
Hydrates with valid value -> updates to null (removing the tag) -> updates to new value, a new tag with a id is generated (?)
It's possible this worked in v1 as there was an arbitrary delay when flushing the tags (around ~10ms), in v2 it just attaches to nextTick() so the renders are a lot quicker
@harlan-zw I've updated with a reproduction:
https://stackblitz.com/edit/github-pfsubbgy?file=app.vue
It's possible this worked in v1 as there was an arbitrary delay when flushing the tags (around ~10ms), in v2 it just attaches to
nextTick()so the renders are a lot quicker
Is there a way to make this functional in v2?
UPDATE: I added an artificial delay in the component (e.g. root of script setup await new Promise((resolve) => setTimeout(resolve, 0))) and this does indeed allow time for the render to complete as expected.
Is it possible to update my implementation, or a config setting, so that the render actually waits for the flush or something before resolving?
Hmm this is very strange 🤔 I'm guessing it's related to why styles only updates on the second click https://stackblitz.com/edit/github-pfsubbgy-5nvqojuf?file=app.vue (may be a Nuxt core issue)
If I update the composable to add an artificial delay alongside computedAsync from @vueuse/core it seems to resolve the issue for me:
import type { ComputedRef } from 'vue'
import { computedAsync } from '@vueuse/core'
export default function useCustomStyles(styles: ComputedRef<string>, id?: string) {
const componentId = id || useId()
/**
* We utilize the `computedAsync` function from `@vueuse/core` to ensure the styles are applied after unhead's internal `await nextTick()` has resolved
* by implementing an artificial delay.
*
* !IMPORTANT: This delay should only be applied in the client-side context.
*
* This should have no impact on the SSR/normal rendering of the parent component.
*/
const computedStyles = computedAsync(async (): Promise<string | undefined> => {
if (import.meta.client) {
// Wait to ensure the styles are available (don't use `nextTick()` here)
await new Promise((resolve) => setTimeout(resolve, 0))
}
if (!styles.value || String(styles.value || '').trim().length === 0) {
return
}
return parseCustomCss(wrapString(styles.value, `#${componentId} {`, '}'))
},
// Initial state
undefined,
)
useHead({
style: [
() => computedStyles.value ? {
key: `custom-styles-${componentId}`,
id: `custom-styles-${componentId}`,
'data-testid': `custom-styles-${componentId}`,
innerHTML: computedStyles.value,
tagPosition: 'head',
tagPriority: 2000,
} : {},
],
})
return {
componentId,
}
}
Hmm nice, I'm still lost on the issue. If you're keen to find a proper solution a reproduction with just Unhead + Vue SSR may make it easier to debug https://stackblitz.com/edit/github-1ftqrmwn?file=README.md