VueUse Head v1 release

This post aims to provide some insight into the v1 release, what's new and what's changed.

If you prefer, feel free to jump straight to the release notes.

Taking over as maintainer

A few months ago I was decided to fix a quick bug on @vueuse/head. I fixed that bug and decided to fix another.

At the time there was no one actively maintaining the package, besides antfu, kindly reviewing any PRs which got sent. EGOIST did an awesome job on it initially, but he was now busy with other projects.

I decided that taking over as maintainer would be a good way to contribute more directly to the Vue ecosystem. A quick discussion with Anthony later and I was given the role.

Taking over maintenance my goal was to close all open issues, improve performance, documentation and overall developer experience. All I hope have now been achieved with this release.

The v1 release is now shipped in Nuxt.

I'll be doing a talk on this release at the upcoming NuxtNation, make sure you catch it!

Pre v1 Achievements

Taking over maintenance was pretty daunting. There were a number of tricky problems to solve.

I started with some low-hanging fruit: useHead TypeScript support (6f919c7).

This one was pretty tedious, but well worth it for developer experience. In creating the types, I made the zhead package to share the types with the ecosystem and some other small utils.

Next up was computed getter support (b6d74dbeb).

With VueUse 9, the recommended way to deal with computed data was with a computed getter, which is simply a function. However, useHead only let you use computed or ref. This was a pretty easy fix, however it illuminated a major performance bottleneck.

So my full attention went into performance (e1bc8d2, 691bcc8).

🏎️ And with that the package was now ~5x faster in patching the DOM.

Why a major bump?

While most issues were closed and easy to solve, there was a major outstanding issue that I wanted to address, Server Only Tags (see discussion). There was also a nasty issue with tags disappearing unexpectedly when hydrating.

Both of these were blocked by how the DOM patching was designed. The old strategy was to add state to the DOM and use this to determine what tags to remove.

<html data-head-attrs="lang,dir">
<head>
<meta name="head:count" content="29">
</head>
<body data-head-attrs="class">
</body>
</html>

You can see in the above, we're tracking the attributes being added and the number of tags. The head:count element is used as an anchor to start rendering tags from.

It would take all tags to be rendered, check the dom elements upwards from the head:count meta tag if any needed to be inserted or if they already existed, otherwise delete them.

This had a issues when you look closely:

Deleting DOM elements it doesn't own

It would modify whatever element is in the position regardless if it's an element created from @vueuse/head.

This led to issues with a third-party script inserting something in between these DOM elements, and it getting deleted.

It also blocked server only tags, as the client would overwrite them when hydrating.

Performance

Previous DOM patching would delete and re-render tags which shared dedupe keys. Not noticeable functionally but a performance consideration.

To solve these issues, a new DOM patching algorithm was needed that tracked side effects gracefully.

State in DOM isn't so nice

Purely from a DX perspective, it's not nice to have state in the DOM that isn't needed. It's not a big deal, but it's not ideal.

Unhead

This was a major piece of work, and I figured if I'm going to have to refactor the entire package, I should aim to also solve another outstanding issue, Universal Head Management.

So I started on Unhead, a Universal document tag manager.

My initial concern was just feature parity with @vueuse/head while supporting server tags, but I quickly realised that I could do better.

So Unhead was born. It has first-party support for Vue, but there is planned work to support any and all other frameworks (eventually).

@vueuse/head v1 is now a thin wrapper for unhead, while being fully backwards compatible.

There are too many enhancements and feature improvements to mention here, check the below release notes or jump straight to the docs.

I'm excited about this new package, and I hope you are too! I'll be writing a dedicated blog post about it soon.

v1.0.0 Release

🤖 Core

Now powered by unhead.

Featuring:

  • a new DOM patching algorithm that tracks side effects gracefully, less aggressive removal of tags and attributes
  • ⚡ DOM rendering optimisations, 5x faster (~10ms for an avg site), async for quicker initial main thread load.
  • 🧹 No more non-essential state in DOM

✨ Enhancements

  • Vue 2.7 Support (docs)
  • Options API Support (docs)

htmlAttrs / bodyAttrs merging

Documentation

Now merged by default instead of replace.

useHead({
  htmlAttrs: {
    class: 'my-class',
  },
})
// we don't want that class to be on a specific page, instead we want a new class
useHead({
  htmlAttrs: {
    class: 'another-class',
  },
})
// <html class="my-class another-class">

Array / Object Classes

Documentation

When using the htmlAttrs or bodyAttrs options, you can use the class attribute to add classes to the html or body elements.

const darkMode = false
useHead({
  htmlAttrs: {
    class: {
      // will be rendered
      dark: darkMode,
      // will not be rendered
      light: !darkMode,
    }
  },
  bodyAttrs: { class: ['layout-id', 'page-id'] }
})

Better deduping

Documentation

Tag deduping is now vastly improved. It's likely you won't need key anymore.

Includes support for meta content array support, reduces boilerplate by using arrays for meta tags.

useHead({
  meta: [
    {
      name: 'og:image',
      content: [
        'https://example.com/image.png',
        'https://example.com/image2.png',
      ],
    },
  ],
})

Prop promises

You can provide a promise to props and it will be resolved when rendering the tags.

useHead({
  script: [
    {
      children: new Promise(resolve => resolve('console.log("hello world")'))
    },
  ],
})

🚀 New Features

useServerHead

Documentation

Lets you render tags on the server only. This has the same API as useHead.

useServerHead({
  scripts: [
    {
      // this wouldn't work on the client, so we use useServerHead
      src: import('~/assets/my-script.js?url'),
    }
  ]
})

useSeoMeta

Documentation

Define meta tags in a flat object, fully typed.

useSeoMeta({
  description: 'My about page',
  ogDescription: 'Still about my about page',
  ogTitle: 'About',
  ogImage: 'https://example.com/image.png',
  twitterCard: 'summary_large_image',
})

tagPosition

Documentation

Lets you define the position of a tag in the DOM.

useHead({
  script: [
    {
      src: 'https://example.com/script.js',
      tagPosition: 'bodyOpen',
    }
  ]
})

tagPriority

Documentation

Lets you define the priority of a tag with a number or string.

useHead({
  script: [{ key: 'not-important', src: '/not-important-script.js', },],
})
useHead({
  script: [
    {
      // script is the tag name to target, `not-important` is the key we're targeting
      tagPriority: 'before:script:not-important',
      src: '/must-be-first-script.js',
    },
  ],
})

tagDuplicateStrategy

Documentation

DOM Event Handlers

Documentation

Function support for DOM event handlers.

useHead({
  bodyAttrs: {
    onresize: (e) => {
      console.log('resized', e)
    }
  },
  script: [
    {
      src: 'https://example.com/analytics.js',
      onload: (el) => {
        console.log('loaded', el)
      }
    }
  ]
})

Hooks

Engine is now powered by hooks, provided by hookable. This allows you to hook into any of the core functionality.

See API hooks and Infer SEO MetaTags, not documented properly yet.

New shortcut composables

Same API as useHead, but targeted as a specific tag type.

  • useTagTitle
  • useTagBase
  • useTagMeta
  • useTagLink
  • useTagScript
  • useTagStyle
  • useTagNoscript
  • useHtmlAttrs
  • useBodyAttrs
  • useTitleTemplate

Migration Guide

⚠️ Breaking changes are minimal, but there are some changes to be aware of.

Please report any issues you find and they will fixed be promptly.

You may consider using @unhead/vue directly if you don't need @vueuse/head.

Verify your tags

The new DOM patching algorithm has not been tested in all possible scenarios, it's possible that there are unforeseen edge cases.

htmlAttrs and bodyAttrs merge strategy

If you had built your code around with the assumption that setting htmlAttrs or bodyAttrs would clear the old tags, this is now different.

// old
useHead({
  htmlAttrs: {
    class: 'my-class',
  },
})

useHead({
  htmlAttrs: {
    class: 'new-class',
  },
})

// <html class="new-class">
// new
useHead({
  htmlAttrs: {
    class: 'my-class',
  },
})

useHead({
  htmlAttrs: {
    class: 'new-class',
  },
})

// <html class="my-class new-class">

Check the documentation to learn more.

Duplicate tags in useHead

To make duplicate tag handling more intuitive, duplicate tags are now allowed in the same useHead call.

Previously you would have to use key to differentiate them.

// old - key is required
useHead({
  meta: [
    { name: 'og:locale:alternate', content: 'es_ES', key: 'locale_es' },
    { name: 'og:locale:alternate', content: 'fr_FR', key: 'locale_fr' },
  ],
})

// <meta name="og:locale:alternate" content="es_ES">
// <meta name="og:locale:alternate" content="fr_FR">

You can safely remove key now.

// new - key is no longer required
useHead({
  meta: [
    { name: 'og:locale:alternate', content: 'es_ES' },
    { name: 'og:locale:alternate', content: 'fr_FR' },
  ],
})
// <meta name="og:locale:alternate" content="es_ES">
// <meta name="og:locale:alternate" content="fr_FR">

It's worth checking that if you did have duplicate tags in the same entry, you will now be rendering both of them potentially.

Next Steps

If you have any issues and/or questions related to v1, please comment in the discussion.

If you have any ideas on the future of unhead and @vueuse/head please get in touch with me through an issue, Discord or Twitter.

Thanks for reading!