Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 9c83934

Browse files
authored
fix(validate): false positives for warmup preloads and charset position (#732)
- Allow `preload` + `fetchpriority="low"` for `as="script"` (the warmup pattern used by `useScript` to start fetching at low priority). - Skip `preload-async-defer-conflict` when the preload uses `fetchpriority="low"` for the same reason. - Run `charset-not-early` only on SSR (DOM order is already set after hydration), and sort by capo weight while filtering virtual tags (`templateParams`, `titleTemplate`) so they don't inflate the position count. - Pass tag references to several `report()` calls so consumers can surface the offending tag in error messages.
1 parent d03116f commit 9c83934

2 files changed

Lines changed: 77 additions & 22 deletions

File tree

packages/unhead/src/plugins/validate.ts

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -470,8 +470,10 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) {
470470
// === Performance Hints ===
471471
// Inspired by webperf-snippets (https://webperf-snippets.nucliweb.net/)
472472

473-
// Preload + fetchpriority="low" is contradictory
474-
if (tag.tag === 'link' && props.rel === 'preload' && props.fetchpriority === 'low')
473+
// Preload + fetchpriority="low" without a matching low-priority script is contradictory
474+
// Note: preload + fetchpriority="low" is a valid warmup pattern (used by useScript)
475+
// to hint the browser to start fetching early at low priority
476+
if (tag.tag === 'link' && props.rel === 'preload' && props.fetchpriority === 'low' && props.as !== 'script')
475477
report('preload-fetchpriority-conflict', `Preload with fetchpriority="low" is contradictory — preload signals critical, low priority contradicts that.`, 'warn', tag)
476478

477479
// Inline style size check (14KB critical CSS budget)
@@ -498,11 +500,11 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) {
498500
// Canonical vs og:url mismatch
499501
const ogUrl = metaByKey.get('og:url')
500502
if (canonicalHref && ogUrl?.props.content && canonicalHref !== ogUrl.props.content)
501-
report('canonical-og-url-mismatch', `Canonical URL "${canonicalHref}" differs from og:url "${ogUrl.props.content}".`, 'warn')
503+
report('canonical-og-url-mismatch', `Canonical URL "${canonicalHref}" differs from og:url "${ogUrl.props.content}".`, 'warn', ogUrl)
502504

503505
// og:image without dimensions
504506
if (metaByKey.has('og:image') && (!metaByKey.has('og:image:width') || !metaByKey.has('og:image:height')))
505-
report('og-image-missing-dimensions', `og:image is set but og:image:width and/or og:image:height are missing — social platforms may not display the image.`, 'warn')
507+
report('og-image-missing-dimensions', `og:image is set but og:image:width and/or og:image:height are missing — social platforms may not display the image.`, 'warn', metaByKey.get('og:image'))
506508

507509
// OG tags without og:title or og:description
508510
if (hasOgTags) {
@@ -552,9 +554,10 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) {
552554
}
553555

554556
// Preload + async/defer script conflict (priority escalation anti-pattern)
557+
// Skip when the preload has fetchpriority="low" as this is a valid warmup pattern (used by useScript)
555558
const preloadScriptHrefs = new Map<string, HeadTag>()
556559
for (const tag of tags) {
557-
if (tag.tag === 'link' && tag.props.rel === 'preload' && tag.props.as === 'script' && tag.props.href)
560+
if (tag.tag === 'link' && tag.props.rel === 'preload' && tag.props.as === 'script' && tag.props.href && tag.props.fetchpriority !== 'low')
558561
preloadScriptHrefs.set(tag.props.href, tag)
559562
}
560563
for (const tag of tags) {
@@ -582,8 +585,9 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) {
582585

583586
// Missing TemplateParamsPlugin (silent breakage — %params appear literally)
584587
if (!head.plugins.has('template-params')) {
585-
if (tags.some((t: HeadTag) => t.tag === 'templateParams'))
586-
report('missing-template-params-plugin', `templateParams are set but TemplateParamsPlugin is not registered. In v3, this plugin is opt-in. Add it to createHead({ plugins: [TemplateParamsPlugin] }).`, 'warn')
588+
const tpTag = tags.find((t: HeadTag) => t.tag === 'templateParams')
589+
if (tpTag)
590+
report('missing-template-params-plugin', `templateParams are set but TemplateParamsPlugin is not registered. In v3, this plugin is opt-in. Add it to createHead({ plugins: [TemplateParamsPlugin] }).`, 'warn', tpTag)
587591
}
588592

589593
// Missing AliasSortingPlugin (silent breakage — before:/after: priorities ignored)
@@ -627,22 +631,27 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) {
627631
}
628632

629633
// charset meta should appear early in head
630-
const { maxPosition: charsetMaxPos } = resolveOptions(ruleConfig, 'charset-not-early', { maxPosition: 3 })
631-
let headIndex = 0
632-
let charsetTag: HeadTag | undefined
633-
let charsetPosition = -1
634-
for (const tag of tags) {
635-
if (tag.tagPosition && tag.tagPosition !== 'head')
636-
continue
637-
headIndex++
638-
if (tag.tag === 'meta' && ('charset' in tag.props || tag.props['http-equiv']?.toLowerCase() === 'content-type')) {
639-
charsetTag = tag
640-
charsetPosition = headIndex
641-
break
634+
// Only check on SSR where we control the render order; on the client the DOM order
635+
// is already set by SSR and capo weights ensure charset is placed early.
636+
if (head.ssr) {
637+
const { maxPosition: charsetMaxPos } = resolveOptions(ruleConfig, 'charset-not-early', { maxPosition: 3 })
638+
const headElementTags = new Set(['title', 'base', 'meta', 'link', 'style', 'script', 'noscript'])
639+
const sortedHeadTags = tags
640+
.filter((t: any) => headElementTags.has(t.tag) && (!t.tagPosition || t.tagPosition === 'head'))
641+
.sort((a: any, b: any) => (a._w ?? 100) === (b._w ?? 100) ? (a._p ?? 0) - (b._p ?? 0) : (a._w ?? 100) - (b._w ?? 100))
642+
let charsetTag: HeadTag | undefined
643+
let charsetPosition = -1
644+
for (let i = 0; i < sortedHeadTags.length; i++) {
645+
const tag = sortedHeadTags[i]
646+
if (tag.tag === 'meta' && ('charset' in tag.props || tag.props['http-equiv']?.toLowerCase() === 'content-type')) {
647+
charsetTag = tag
648+
charsetPosition = i + 1
649+
break
650+
}
642651
}
652+
if (charsetTag && charsetPosition > charsetMaxPos)
653+
report('charset-not-early', `<meta charset> is at position ${charsetPosition} in <head>. It should be within the first ${charsetMaxPos} tags so the browser doesn't need to re-parse.`, 'warn', charsetTag)
643654
}
644-
if (charsetTag && charsetPosition > charsetMaxPos)
645-
report('charset-not-early', `<meta charset> is at position ${charsetPosition} in <head>. It should be within the first ${charsetMaxPos} tags so the browser doesn't need to re-parse.`, 'warn', charsetTag)
646655

647656
// preload as="script" when the actual script is type="module" should use modulepreload
648657
const moduleScriptSrcs = new Set<string>()

packages/unhead/test/unit/plugins/validate.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,7 @@ describe('validatePlugin', () => {
605605
})
606606

607607
describe('performance hints', () => {
608-
it('warns on preload with fetchpriority="low"', () => {
608+
it('warns on preload with fetchpriority="low" for non-script assets', () => {
609609
const { head, rules } = createValidationHead()
610610
head.push({
611611
link: [{ rel: 'preload', href: '/font.woff2', as: 'font', crossorigin: 'anonymous', fetchpriority: 'low' as const }],
@@ -614,6 +614,15 @@ describe('validatePlugin', () => {
614614
expect(rules.find(r => r.id === 'preload-fetchpriority-conflict')).toBeTruthy()
615615
})
616616

617+
it('does not warn on preload with fetchpriority="low" for scripts (valid warmup pattern)', () => {
618+
const { head, rules } = createValidationHead()
619+
head.push({
620+
link: [{ rel: 'preload', href: '/analytics.js', as: 'script' as const, fetchpriority: 'low' as const }],
621+
})
622+
renderSSRHead(head)
623+
expect(rules.find(r => r.id === 'preload-fetchpriority-conflict')).toBeFalsy()
624+
})
625+
617626
it('does not warn on preload with fetchpriority="high"', () => {
618627
const { head, rules } = createValidationHead()
619628
head.push({
@@ -703,6 +712,26 @@ describe('validatePlugin', () => {
703712
expect(rules.find(r => r.id === 'preload-async-defer-conflict')).toBeFalsy()
704713
})
705714

715+
it('does not warn on preload + defer script when preload has fetchpriority="low" (useScript warmup)', () => {
716+
const { head, rules } = createValidationHead()
717+
head.push({
718+
link: [{ rel: 'preload', href: '/gtm.js', as: 'script' as const, fetchpriority: 'low' as const }],
719+
script: [{ src: '/gtm.js', defer: true }],
720+
})
721+
renderSSRHead(head)
722+
expect(rules.find(r => r.id === 'preload-async-defer-conflict')).toBeFalsy()
723+
})
724+
725+
it('does not warn on preload + async script when preload has fetchpriority="low"', () => {
726+
const { head, rules } = createValidationHead()
727+
head.push({
728+
link: [{ rel: 'preload', href: '/analytics.js', as: 'script' as const, fetchpriority: 'low' as const }],
729+
script: [{ src: '/analytics.js', async: true }],
730+
})
731+
renderSSRHead(head)
732+
expect(rules.find(r => r.id === 'preload-async-defer-conflict')).toBeFalsy()
733+
})
734+
706735
it('warns on prefetch + preload conflict', () => {
707736
const { head, rules } = createValidationHead()
708737
head.push({
@@ -1014,6 +1043,23 @@ describe('validatePlugin', () => {
10141043
expect(rules.find(r => r.id === 'charset-not-early')).toBeFalsy()
10151044
})
10161045

1046+
it('skips virtual tags (templateParams, titleTemplate) when counting charset position', () => {
1047+
const { head, rules } = createValidationHead({ rules: { 'charset-not-early': ['warn', { maxPosition: 2 }] } })
1048+
head.push({
1049+
templateParams: { site: { name: 'Test' } } as any,
1050+
titleTemplate: '%s | %site.name' as any,
1051+
})
1052+
head.push({
1053+
title: 'Home',
1054+
})
1055+
head.push({
1056+
meta: [{ charset: 'utf-8' }],
1057+
})
1058+
renderSSRHead(head)
1059+
// charset should be position 2 (title=1, charset=2), not 4 (templateParams=1, titleTemplate=2, title=3, charset=4)
1060+
expect(rules.find(r => r.id === 'charset-not-early')).toBeFalsy()
1061+
})
1062+
10171063
it('warns on preload as="script" for module script (should use modulepreload)', () => {
10181064
const { head, rules } = createValidationHead()
10191065
head.push({

0 commit comments

Comments
 (0)