Skip to content

Commit eaad428

Browse files
authored
perf(unhead): skip template parsing without tokens (#937)
* test(unhead): pin SSR hot path behavior * perf(unhead): skip absent template substitutions * test(unhead): conform SSR hot path title
1 parent 8f88762 commit eaad428

2 files changed

Lines changed: 106 additions & 4 deletions

File tree

packages/unhead/src/plugins/templateParams.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ const SupportedAttrs: Partial<Record<string, string>> = {
1010

1111
const contentAttrs: (keyof Pick<HeadTag, 'innerHTML' | 'textContent'>)[] = ['innerHTML', 'textContent']
1212

13+
function processIfNeeded(value: string, params: TemplateParams, separator: string, isJson = false) {
14+
return typeof value === 'string' && value.includes('%')
15+
? processTemplateParams(value, params, separator, isJson)
16+
: value
17+
}
18+
1319
export const TemplateParamsPlugin = /* @__PURE__ */ defineHeadPlugin((head) => {
1420
return {
1521
key: 'template-params',
@@ -21,7 +27,7 @@ export const TemplateParamsPlugin = /* @__PURE__ */ defineHeadPlugin((head) => {
2127
const sep = params.separator || '|'
2228
delete params.separator
2329
// pre-process title
24-
params.pageTitle = processTemplateParams(
30+
params.pageTitle = processIfNeeded(
2531
// find templateParams
2632
params.pageTitle as string || head._title || '',
2733
params,
@@ -33,13 +39,13 @@ export const TemplateParamsPlugin = /* @__PURE__ */ defineHeadPlugin((head) => {
3339
}
3440
const v = SupportedAttrs[tag.tag]
3541
if (v && typeof tag.props[v] === 'string') {
36-
tag.props[v] = processTemplateParams(tag.props[v], params, sep)
42+
tag.props[v] = processIfNeeded(tag.props[v], params, sep)
3743
}
3844
// everything else requires explicit opt-in
3945
else if (tag.processTemplateParams || tag.tag === 'titleTemplate' || tag.tag === 'title') {
4046
for (const p of contentAttrs) {
4147
if (typeof tag[p] === 'string')
42-
tag[p] = processTemplateParams(tag[p], params, sep, tag.tag === 'script' && typeof tag.props.type === 'string' && tag.props.type.endsWith('json'))
48+
tag[p] = processIfNeeded(tag[p], params, sep, tag.tag === 'script' && typeof tag.props.type === 'string' && tag.props.type.endsWith('json'))
4349
}
4450
}
4551
}
@@ -51,7 +57,7 @@ export const TemplateParamsPlugin = /* @__PURE__ */ defineHeadPlugin((head) => {
5157
// we need to re-process in case then user had a function as the titleTemplate
5258
const title: HeadTag | undefined = tagMap.get('title')
5359
if (title?.textContent && title.processTemplateParams !== false) {
54-
title.textContent = processTemplateParams(title.textContent, head._templateParams!, head._separator!)
60+
title.textContent = processIfNeeded(title.textContent, head._templateParams!, head._separator!)
5561
}
5662
},
5763
},
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { HeadTag, PropResolver } from '../../../src/types'
2+
import { describe, expect, it } from 'vitest'
3+
import { createHead, renderSSRHead } from '../../../src/server'
4+
import { normalizeEntryToTags } from '../../../src/utils'
5+
6+
describe('ssr hot path invariants', () => {
7+
it('resolves a wrapped root function before normalizing primitive tags', () => {
8+
const calls: Array<string | undefined> = []
9+
const resolver: PropResolver = (key, value) => {
10+
calls.push(key)
11+
return value && typeof value === 'object' && 'current' in value ? value.current : value
12+
}
13+
14+
const tags = normalizeEntryToTags({
15+
current: () => ({
16+
noscript: 'fallback',
17+
style: 'body { color: red }',
18+
title: 'Resolved title',
19+
}),
20+
}, [resolver])
21+
22+
expect(calls.slice(0, 2)).toEqual([undefined, undefined])
23+
expect(tags).toEqual([
24+
{ tag: 'noscript', props: {}, innerHTML: 'fallback' },
25+
{ tag: 'style', props: {}, innerHTML: 'body { color: red }' },
26+
{ tag: 'title', props: {}, textContent: 'Resolved title' },
27+
])
28+
})
29+
30+
it('preserves hook order, resolved tag identity, and later-entry precedence', () => {
31+
const order: string[] = []
32+
const resolvedTags: HeadTag[] = []
33+
const head = createHead({
34+
disableDefaults: true,
35+
hooks: {
36+
'entries:resolve': () => order.push('entries:resolve'),
37+
'entries:normalize': () => order.push('entries:normalize'),
38+
'tags:beforeResolve': ({ tags }) => {
39+
order.push('tags:beforeResolve')
40+
resolvedTags.push(tags[0])
41+
},
42+
'tags:resolve': ({ tags }) => {
43+
order.push('tags:resolve')
44+
resolvedTags.push(tags[0])
45+
},
46+
'tags:afterResolve': ({ tags }) => {
47+
order.push('tags:afterResolve')
48+
resolvedTags.push(tags[0])
49+
},
50+
'ssr:render': ({ tags }) => {
51+
order.push('ssr:render')
52+
resolvedTags.push(tags[0])
53+
},
54+
'ssr:rendered': ({ tags }) => {
55+
order.push('ssr:rendered')
56+
resolvedTags.push(tags[0])
57+
},
58+
},
59+
})
60+
head.push({ meta: [{ name: 'description', content: 'first' }] })
61+
head.push({ meta: [{ name: 'description', content: 'second' }] })
62+
63+
const result = renderSSRHead(head)
64+
65+
expect(order).toEqual([
66+
'entries:resolve',
67+
'entries:normalize',
68+
'entries:normalize',
69+
'tags:beforeResolve',
70+
'tags:resolve',
71+
'tags:afterResolve',
72+
'ssr:render',
73+
'ssr:rendered',
74+
])
75+
expect(resolvedTags.every(tag => tag === resolvedTags[0])).toBe(true)
76+
expect(result.headTags).toBe('<meta name="description" content="second">')
77+
})
78+
79+
it('filters unsafe attributes added at the SSR render boundary', () => {
80+
const inherited = { onload: 'alert(1)' }
81+
const head = createHead({
82+
disableDefaults: true,
83+
hooks: {
84+
'ssr:render': ({ tags }) => {
85+
const props = Object.assign(Object.create(inherited), tags[0].props)
86+
props['bad name'] = 'unsafe'
87+
props.title = 'safe" onload="alert(1)'
88+
tags[0].props = props
89+
},
90+
},
91+
})
92+
head.push({ meta: [{ name: 'description', content: 'safe' }] })
93+
94+
expect(renderSSRHead(head).headTags).toBe('<meta name="description" content="safe" title="safe&quot; onload=&quot;alert(1)">')
95+
})
96+
})

0 commit comments

Comments
 (0)