Skip to content

Commit ee34114

Browse files
authoredFeb 26, 2025··
perf(core): normalize entries once (#504)
1 parent 41e3c2f commit ee34114

13 files changed

+416
-110
lines changed
 

‎bench/ssr-perf.bench.ts

-62
This file was deleted.

‎bench/tag-normalize.bench.ts

-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { normaliseEntryTags } from 'unhead/utils'
2-
import { normalizeEntryTags } from 'unhead/utils/normalize'
32
import { bench, describe } from 'vitest'
43

54
describe('tag normalize', () => {

‎bench/templateParams.bench.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { processTemplateParams } from 'unhead/utils'
12
import { bench, describe } from 'vitest'
2-
import { processTemplateParams } from '../src/templateParams'
33

44
describe('processTemplateParams', () => {
55
bench('basic', () => {

‎packages/schema-org/src/plugin.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,8 @@ export function SchemaOrgUnheadPlugin(config: MetaInput, meta: () => Partial<Met
3737
return {
3838
key: 'schema-org',
3939
hooks: {
40-
'entries:resolve': () => {
41-
graph = createSchemaOrgGraph()
42-
},
4340
'entries:normalize': async ({ tags }) => {
41+
graph = graph || createSchemaOrgGraph()
4442
for (const tag of tags) {
4543
if (tag.tag === 'script' && tag.props.type === 'application/ld+json' && tag.props.nodes) {
4644
// this is a bit expensive, load in seperate chunk

‎packages/unhead/src/createHead.ts

+27-23
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export function createHeadCore<T = Head>(resolvedOptions: CreateHeadOptions = {}
3737

3838
const entries: Map<number, HeadEntry<T>> = new Map()
3939
const plugins: Map<string, HeadPlugin> = new Map()
40+
const normalizeQueue: number[] = []
4041
const head: Unhead<T> = {
4142
_entryCount: 1, // 0 is reserved for internal use
4243
plugins,
@@ -53,25 +54,24 @@ export function createHeadCore<T = Head>(resolvedOptions: CreateHeadOptions = {}
5354
const options = { ..._options } as Required<HeadEntry<any>>['options']
5455
// @ts-expect-error untyped
5556
delete options.head
56-
const _i = head._entryCount++
57+
const _i = _options._index ?? head._entryCount++
58+
const inst = { _i, input, options }
5759
const _: ActiveHeadEntry<T> = {
58-
_poll() {
60+
_poll(rm = false) {
5961
head.dirty = true
62+
!rm && normalizeQueue.push(_i)
6063
hooks.callHook('entries:updated', head)
6164
},
6265
dispose() {
6366
if (entries.delete(_i)) {
64-
_._poll()
67+
_._poll(true)
6568
}
6669
},
6770
// a patch is the same as creating a new entry, just a nice DX
6871
patch(input) {
6972
if (!options.mode || (options.mode === 'server' && ssr) || (options.mode === 'client' && !ssr)) {
70-
entries.set(_i, {
71-
_i,
72-
input,
73-
options,
74-
})
73+
inst.input = input
74+
entries.set(_i, inst)
7575
_._poll()
7676
}
7777
},
@@ -86,23 +86,27 @@ export function createHeadCore<T = Head>(resolvedOptions: CreateHeadOptions = {}
8686
entries: [...head.entries.values()],
8787
}
8888
await hooks.callHook('entries:resolve', ctx)
89-
const allTags = []
90-
let hasFlatMeta = false
91-
for (const e of ctx.entries) {
92-
const normalizeCtx = {
93-
tags: normalizeEntryToTags(e.input, resolvedOptions.propResolvers || [])
94-
.map(t => Object.assign(t, e.options)),
95-
entry: e,
89+
while (normalizeQueue.length) {
90+
const i = normalizeQueue.shift()!
91+
const e = entries.get(i)
92+
if (e) {
93+
const normalizeCtx = {
94+
tags: normalizeEntryToTags(e.input, resolvedOptions.propResolvers || [])
95+
.map(t => Object.assign(t, e.options)),
96+
entry: e,
97+
}
98+
await hooks.callHook('entries:normalize', normalizeCtx)
99+
e._tags = normalizeCtx.tags.map((t, i) => {
100+
t._w = tagWeight(head, t)
101+
t._p = (e._i << 10) + i
102+
t._d = dedupeKey(t)
103+
return t
104+
})
96105
}
97-
await hooks.callHook('entries:normalize', normalizeCtx)
98-
allTags.push(...normalizeCtx.tags.map((t, i) => {
99-
t._w = tagWeight(head, t)
100-
t._p = (e._i << 10) + i
101-
t._d = dedupeKey(t)
102-
return t
103-
}))
104106
}
105-
allTags
107+
let hasFlatMeta = false
108+
ctx.entries
109+
.flatMap(e => e._tags || [])
106110
.sort(sortTags)
107111
.reduce((acc, next) => {
108112
const k = String(next._d || next._p)

‎packages/unhead/src/plugins/inferSeoMetaPlugin.ts

+8-8
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ export function InferSeoMetaPlugin(options: InferSeoMetaPluginOptions = {}) {
3131
tagPriority: 'low',
3232
},
3333
{
34-
property: 'og:title',
35-
content: '%infer',
36-
tagPriority: 'low',
34+
'property': 'og:title',
35+
'tagPriority': 'low',
36+
'data-infer': '',
3737
},
3838
{
39-
property: 'og:description',
40-
content: '%infer',
41-
tagPriority: 'low',
39+
'property': 'og:description',
40+
'tagPriority': 'low',
41+
'data-infer': '',
4242
},
4343
],
4444
})
@@ -50,7 +50,7 @@ export function InferSeoMetaPlugin(options: InferSeoMetaPluginOptions = {}) {
5050
const titleTemplate = head._titleTemplate
5151
// check if the current title is %infer
5252
const ogTitle = tagMap.get('meta:og:title')
53-
if (ogTitle?.props?.content === '%infer') {
53+
if (typeof ogTitle?.props['data-infer'] !== 'undefined') {
5454
if (titleTemplate) {
5555
// @ts-expect-error broken types
5656
title = typeof titleTemplate === 'function' ? titleTemplate(title) : titleTemplate.replace('%s', title)
@@ -60,7 +60,7 @@ export function InferSeoMetaPlugin(options: InferSeoMetaPluginOptions = {}) {
6060

6161
const description = tagMap.get('meta:description')?.props?.content
6262
const ogDescription = tagMap.get('meta:og:description')
63-
if (ogDescription?.props?.content === '%infer') {
63+
if (typeof ogDescription?.props['data-infer'] !== 'undefined') {
6464
ogDescription.props!.content = options.ogDescription ? options.ogDescription(description) : description || ''
6565
}
6666
},

‎packages/unhead/src/server/transformHtmlTemplate.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { extractUnheadInputFromHtml } from './util/extractUnheadInputFromHtml'
44

55
export async function transformHtmlTemplate(head: Unhead<any>, html: string, options?: RenderSSRHeadOptions) {
66
const { html: parsedHtml, input } = extractUnheadInputFromHtml(html)
7-
head.entries.set(0, { _i: 0, input, options: {} })
7+
head.push(input, { _index: 0 })
88
const headHtml = await renderSSRHead(head, options)
99
return parsedHtml
1010
.replace('<html>', `<html${headHtml.htmlAttrs}>`)

‎packages/unhead/src/types/head.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export interface ActiveHeadEntry<Input> {
9292
/**
9393
* @internal
9494
*/
95-
_poll: () => void
95+
_poll: (rm?: boolean) => void
9696
}
9797

9898
export type PropResolver = (key: string, value: any, tag?: HeadTag) => any
@@ -145,6 +145,10 @@ export interface HeadEntryOptions extends TagPosition, TagPriority, ProcessesTem
145145
* @internal
146146
*/
147147
_safe?: boolean
148+
/**
149+
* @internal
150+
*/
151+
_index?: number
148152
}
149153

150154
export interface Unhead<Input = Head> {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { bench, describe } from 'vitest'
2+
import { useHead } from '../../src'
3+
import { createHead, renderSSRHead } from '../../src/server'
4+
5+
describe('ssr bench', () => {
6+
bench('x50 ssr', async () => {
7+
const head = createHead()
8+
const page = {
9+
title: 'Home',
10+
description: 'Home page description',
11+
image: 'https://nuxtjs.org/meta_0.png',
12+
}
13+
useHead(head, {
14+
// de-dupe keys
15+
title: 'bench test',
16+
}, {
17+
head,
18+
})
19+
// do chunks of 100 over 10 iterations
20+
for (const i in Array.from({ length: 10 })) {
21+
for (const i2 in Array.from({ length: 10 })) {
22+
useHead(head, {
23+
// de-dupe keys
24+
title: `${page.title}-${i}/${i2} | Nuxt`,
25+
bodyAttrs: {
26+
style: {
27+
color: 'red',
28+
background: 'blue',
29+
},
30+
class: {
31+
dark: true,
32+
},
33+
},
34+
meta: [
35+
{
36+
name: 'description',
37+
content: `${page.description} ${i}/${i2}`,
38+
},
39+
{
40+
property: 'og:image',
41+
content: `${page.image}?${i}/${i2}`,
42+
},
43+
],
44+
script: [
45+
{
46+
src: `https://example.com/script.js?${i}/${i2}`,
47+
},
48+
],
49+
link: [
50+
{
51+
as: 'style',
52+
href: `https://example.com/style.js?${i}/${i2}`,
53+
},
54+
],
55+
}, {
56+
head,
57+
})
58+
}
59+
await renderSSRHead(head)
60+
}
61+
}, {
62+
iterations: 100,
63+
time: 1000,
64+
})
65+
})

‎packages/unhead/test/unit/plugins/infer-seo-meta.test.ts

+9-9
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ describe('inferSeoMetaPlugin', () => {
2929
<meta name="description" content="My Description">
3030
<meta property="og:image" content="https://example.com/image.jpg">
3131
<meta name="twitter:card" content="summary_large_image">
32-
<meta property="og:title" content="My Title">
33-
<meta property="og:description" content="My Description">"
32+
<meta property="og:title" data-infer="true" content="My Title">
33+
<meta property="og:description" data-infer="true" content="My Description">"
3434
`)
3535
})
3636
it('conflicts', async () => {
@@ -76,7 +76,7 @@ describe('inferSeoMetaPlugin', () => {
7676
"bodyTagsOpen": "",
7777
"headTags": "<title>Title</title>
7878
<meta name="twitter:card" content="summary_large_image">
79-
<meta property="og:title" content="Title">",
79+
<meta property="og:title" data-infer="true" content="Title">",
8080
"htmlAttrs": "",
8181
}
8282
`)
@@ -103,7 +103,7 @@ describe('inferSeoMetaPlugin', () => {
103103
"bodyTagsOpen": "",
104104
"headTags": "<title>Title - My Site</title>
105105
<meta name="twitter:card" content="summary_large_image">
106-
<meta property="og:title" content="Title - My Site">",
106+
<meta property="og:title" data-infer="true" content="Title - My Site">",
107107
"htmlAttrs": "",
108108
}
109109
`)
@@ -128,7 +128,7 @@ describe('inferSeoMetaPlugin', () => {
128128
"bodyTagsOpen": "",
129129
"headTags": "<title>Title</title>
130130
<meta name="twitter:card" content="summary_large_image">
131-
<meta property="og:title" content="Title">",
131+
<meta property="og:title" data-infer="true" content="Title">",
132132
"htmlAttrs": "",
133133
}
134134
`)
@@ -180,7 +180,7 @@ describe('inferSeoMetaPlugin', () => {
180180
"bodyTagsOpen": "",
181181
"headTags": "<title>test</title>
182182
<meta name="twitter:card" content="summary_large_image">
183-
<meta property="og:title" content="test">",
183+
<meta property="og:title" data-infer="true" content="test">",
184184
"htmlAttrs": "",
185185
}
186186
`)
@@ -203,7 +203,7 @@ describe('inferSeoMetaPlugin', () => {
203203
"bodyTagsOpen": "",
204204
"headTags": "<title>Title - My Site</title>
205205
<meta name="twitter:card" content="summary_large_image">
206-
<meta property="og:title" content="Title - My Site">",
206+
<meta property="og:title" data-infer="true" content="Title - My Site">",
207207
"htmlAttrs": "",
208208
}
209209
`)
@@ -232,7 +232,7 @@ describe('inferSeoMetaPlugin', () => {
232232
"bodyTagsOpen": "",
233233
"headTags": "<title>My Site</title>
234234
<meta name="twitter:card" content="summary_large_image">
235-
<meta property="og:title" content="My Site">",
235+
<meta property="og:title" data-infer="true" content="My Site">",
236236
"htmlAttrs": "",
237237
}
238238
`)
@@ -263,7 +263,7 @@ describe('inferSeoMetaPlugin', () => {
263263
"bodyTagsOpen": "",
264264
"headTags": "<title>test</title>
265265
<meta name="twitter:card" content="summary_large_image">
266-
<meta property="og:title" content="test">",
266+
<meta property="og:title" data-infer="true" content="test">",
267267
"htmlAttrs": "",
268268
}
269269
`)

‎packages/unhead/test/unit/server/ssr.test.ts

+8
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ describe('ssr', () => {
2727
"htmlAttrs": " lang="de"",
2828
}
2929
`)
30+
31+
head.push({
32+
...basicSchema,
33+
htmlAttrs: {
34+
lang: 'de',
35+
},
36+
})
37+
await renderSSRHead(head)
3038
})
3139

3240
it('number title', async () => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import type { Head } from 'unhead/types'
2+
import { InferSeoMetaPlugin } from '@unhead/addons'
3+
import { bench, describe } from 'vitest'
4+
import { useHead, useSeoMeta, useServerHead } from '../../src'
5+
import { createHead as createServerHead, renderSSRHead } from '../../src/server'
6+
7+
describe('ssr e2e bench', () => {
8+
bench('e2e', async () => {
9+
// we're going to replicate the logic needed to render the tags for a harlanzw.com page
10+
11+
// 1. Add nuxt.config meta tags
12+
const head = createServerHead()
13+
// nuxt.config app.head
14+
head.push({
15+
title: 'Harlan Wilton',
16+
templateParams: {
17+
separator: '·',
18+
},
19+
script: [
20+
{
21+
'src': 'https://idea-lets-dance.harlanzw.com/script.js',
22+
'data-spa': 'auto',
23+
'data-site': 'VDJUVDNA',
24+
'defer': true,
25+
},
26+
],
27+
})
28+
const options = { mode: 'server' } as const
29+
// 1. payload
30+
head.push({
31+
link: [
32+
// resource hints for vue chunks
33+
{ rel: 'preload', as: 'fetch', href: '/payload.json' },
34+
],
35+
}, options)
36+
// 2. styles
37+
head.push({
38+
link: [
39+
// page css, assume 5 of so css files
40+
{ rel: 'stylesheet', href: '/page.css' },
41+
{ rel: 'stylesheet', href: '/page2.css' },
42+
{ rel: 'stylesheet', href: '/page3.css' },
43+
{ rel: 'stylesheet', href: '/page4.css' },
44+
{ rel: 'stylesheet', href: '/page5.css' },
45+
],
46+
}, options)
47+
// 3. resource hints
48+
head.push({
49+
link: [
50+
{ rel: 'preload', as: 'script', href: '/_nuxt/runtime.js' },
51+
{ rel: 'preload', as: 'script', href: '/_nuxt/vendors.js' },
52+
{ rel: 'preload', as: 'script', href: '/_nuxt/app.js' },
53+
],
54+
}, options)
55+
// 4. payloads
56+
head.push({
57+
script: [
58+
// 4. payloads
59+
{ innerHTML: { id: '__NUXT_DATA__', data: { initial: { bar: 'foo' }, payload: { foo: 'bar' } } } },
60+
],
61+
}, {
62+
...options,
63+
tagPosition: 'bodyClose',
64+
tagPriority: 'high',
65+
})
66+
// 5. scripts
67+
head.push({
68+
script: [
69+
{
70+
type: 'module',
71+
src: '/module.js',
72+
tagPosition: 'bodyClose',
73+
crossorigin: '',
74+
},
75+
{
76+
src: '/non-module.js',
77+
tagPosition: 'bodyClose',
78+
defer: true,
79+
crossorigin: '',
80+
},
81+
],
82+
}, options)
83+
// start the vue rendererer
84+
// Nuxt SEO experiments
85+
head.use({
86+
key: 'nuxt-seo-experiments',
87+
hooks: {
88+
'tags:resolve': async ({ tags }) => {
89+
// iterate through tags that require absolute URLs and add the host base
90+
for (const tag of tags) {
91+
// og:image and twitter:image need to be absolute
92+
if (tag.tag !== 'meta')
93+
continue
94+
if (tag.props.property !== 'og:image:url' && tag.props.property !== 'og:image' && tag.props.name !== 'twitter:image')
95+
continue
96+
if (typeof tag.props.content !== 'string' || !tag.props.content.trim() || tag.props.content.startsWith('http') || tag.props.content.startsWith('//'))
97+
continue
98+
tag.props.content = `https://harlanzw.com${tag.props.content}`
99+
}
100+
},
101+
},
102+
})
103+
head.use(InferSeoMetaPlugin())
104+
const input: Head = {
105+
meta: [],
106+
templateParams: {
107+
site: {
108+
name: 'Harlan Wilton',
109+
url: 'https://harlanzw.com',
110+
description: 'Open source developer, contributing to the Vue, Nuxt, and Vite ecosystems.',
111+
},
112+
// support legacy
113+
siteUrl: 'https://harlanzw.com',
114+
siteName: 'Harlan Wilton',
115+
},
116+
}
117+
input.templateParams!.siteDescription = 'Open source developer, contributing to the Vue, Nuxt, and Vite ecosystems.'
118+
// we can setup a meta description
119+
input.meta!.push(
120+
{
121+
name: 'description',
122+
content: '%site.description',
123+
},
124+
)
125+
head.push(input, { tagPriority: 150 })
126+
// Nuxt SEO
127+
const minimalPriority = {
128+
tagPriority: 101,
129+
head,
130+
} as const
131+
// needs higher priority
132+
useHead({
133+
link: [{ rel: 'canonical', href: 'https://harlanzw.com/' }],
134+
}, {
135+
head,
136+
})
137+
useServerHead({
138+
htmlAttrs: { lang: 'en' },
139+
}, {
140+
head,
141+
})
142+
useHead({
143+
templateParams: { site: {
144+
name: 'Harlan Wilton',
145+
url: 'https://harlanzw.com',
146+
description: 'Open source developer, contributing to the Vue, Nuxt, and Vite ecosystems.',
147+
}, siteName: 'Harlan Wilton' || '' },
148+
titleTemplate: '%s %separator %siteName',
149+
}, minimalPriority)
150+
useSeoMeta({
151+
ogType: 'website',
152+
ogUrl: 'https://harlanzw.com',
153+
ogLocale: 'en',
154+
ogSiteName: 'Harlan Wilton',
155+
description: 'Open source developer, contributing to the Vue, Nuxt, and Vite ecosystems.',
156+
twitterCreator: '@harlan_zw',
157+
twitterSite: '@harlan_zw',
158+
}, { ...minimalPriority, head })
159+
// inferred from path /about (example)
160+
useHead({
161+
title: 'About',
162+
}, { ...minimalPriority, head })
163+
// OG Image
164+
const meta: Head['meta'] = [
165+
{ property: 'og:image', content: 'https://harlanzw.com/__og-image__/og.png' },
166+
{ property: 'og:image:type', content: `image/png` },
167+
{ name: 'twitter:card', content: 'summary_large_image' },
168+
// we don't need this but avoids issue when using useSeoMeta({ twitterImage })
169+
{ name: 'twitter:image', content: 'https://harlanzw.com/__og-image__/og.png' },
170+
{ name: 'twitter:image:src', content: 'https://harlanzw.com/__og-image__/og.png' },
171+
{ property: 'og:image:width', content: 1200 },
172+
{ name: 'twitter:image:width', content: 1200 },
173+
{ property: 'og:image:height', content: 600 },
174+
{ name: 'twitter:image:height', content: 600 },
175+
{ property: 'og:image:alt', content: 'My Image' },
176+
{ name: 'twitter:image:alt', content: 'My Image' },
177+
]
178+
const script: Head['script'] = [{
179+
id: 'nuxt-og-image-options',
180+
type: 'application/json',
181+
processTemplateParams: true,
182+
innerHTML: () => {
183+
const _input: Record<string, any> = {
184+
props: {
185+
color: 'red',
186+
},
187+
}
188+
if (typeof _input.props.title === 'undefined')
189+
_input.props.title = '%s'
190+
delete _input.url
191+
// don't apply defaults
192+
return _input
193+
},
194+
// we want this to be last in our head
195+
tagPosition: 'bodyClose',
196+
}]
197+
useServerHead({
198+
script,
199+
meta,
200+
}, {
201+
head,
202+
tagPriority: 35,
203+
})
204+
// Schema.org
205+
// entry
206+
useServerHead({
207+
script: [{
208+
type: 'application/ld+json',
209+
key: 'schema-org-graph',
210+
nodes: [
211+
212+
],
213+
}],
214+
}, {
215+
head,
216+
})
217+
// Robots
218+
useHead({
219+
meta: [
220+
{ name: 'robots', content: 'index, follow' },
221+
],
222+
}, {
223+
head,
224+
})
225+
// app.vue
226+
// [...all].vue
227+
useSeoMeta({
228+
title: 'Home',
229+
description: 'Home page description',
230+
}, {
231+
head,
232+
})
233+
// index.md
234+
useSeoMeta({
235+
title: 'Home',
236+
description: 'Home page description',
237+
}, {
238+
head,
239+
})
240+
241+
const { headTags, bodyTags, bodyTagsOpen, htmlAttrs, bodyAttrs } = await renderSSRHead(head, {
242+
omitLineBreaks: true,
243+
})
244+
function normalizeChunks(chunks: (string | undefined)[]) {
245+
return chunks.filter(Boolean).map(i => i!.trim())
246+
}
247+
const htmlContext = {
248+
htmlAttrs: htmlAttrs ? [htmlAttrs] : [],
249+
head: normalizeChunks([headTags]),
250+
bodyAttrs: bodyAttrs ? [bodyAttrs] : [],
251+
bodyPrepend: normalizeChunks([bodyTagsOpen]),
252+
bodyAppend: [bodyTags],
253+
}
254+
// eslint-disable-next-line unused-imports/no-unused-vars
255+
const html = `
256+
<!DOCTYPE html>
257+
<html${htmlContext.htmlAttrs.join(' ')}>
258+
<head>
259+
${htmlContext.head.join('\n')}
260+
</head>
261+
<body${htmlContext.bodyAttrs.join(' ')}>
262+
${htmlContext.bodyPrepend.join('\n')}
263+
${htmlContext.bodyAppend.join('\n')}
264+
</body>
265+
`
266+
}, {
267+
iterations: 5000,
268+
})
269+
270+
bench('simple', async () => {
271+
// 1. Add nuxt.config meta tags
272+
const head = createServerHead()
273+
// nuxt.config app.head
274+
head.push({
275+
title: 'Harlan Wilton',
276+
templateParams: {
277+
separator: '·',
278+
},
279+
script: [
280+
{
281+
'src': 'https://idea-lets-dance.harlanzw.com/script.js',
282+
'data-spa': 'auto',
283+
'data-site': 'VDJUVDNA',
284+
'defer': true,
285+
},
286+
],
287+
})
288+
await renderSSRHead(head)
289+
})
290+
})

‎packages/vue/test/unit/promises.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { useHead } from '@unhead/vue'
21
import { describe, it } from 'vitest'
2+
import { useHead } from '../../src'
33
import { PromisesPlugin } from '../../src/plugins'
44
import { ssrVueAppWithUnhead } from '../util'
55

0 commit comments

Comments
 (0)
Please sign in to comment.