unhead icon indicating copy to clipboard operation
unhead copied to clipboard

[v2] Calling useHead in the client after SSR no longer updates the tags in the head but instead removes existing

Open adamdehaven opened this issue 8 months ago • 8 comments

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


adamdehaven avatar Mar 25 '25 14:03 adamdehaven

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.

harlan-zw avatar Mar 25 '25 15:03 harlan-zw

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 textContent over innerHTML if 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 😄

adamdehaven avatar Mar 25 '25 15:03 adamdehaven

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 avatar Mar 25 '25 15:03 harlan-zw

@harlan-zw I've updated with a reproduction:

https://stackblitz.com/edit/github-pfsubbgy?file=app.vue

adamdehaven avatar Mar 25 '25 15:03 adamdehaven

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?

adamdehaven avatar Mar 25 '25 15:03 adamdehaven

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)

harlan-zw avatar Mar 25 '25 16:03 harlan-zw

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,
  }
}

adamdehaven avatar Mar 25 '25 16:03 adamdehaven

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

harlan-zw avatar Mar 25 '25 18:03 harlan-zw