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

Skip to content

Commit 093a277

Browse files
authored
feat(validate): add 7 performance/web vitals validation rules (#725)
* feat(validate): add 7 performance/web vitals validation rules Add render-blocking-script, too-many-fetchpriority-high, defer-on-module-script, duplicate-resource-hint, charset-not-early, preload-not-modulepreload, and preconnect-missing-crossorigin rules to the ValidatePlugin. These catch common performance anti-patterns that impact Core Web Vitals (LCP, INP, CLS) including render-blocking scripts in head, diluted fetch priority signals, redundant defer on modules, duplicate resource hints, late charset declarations, incorrect preload vs modulepreload usage, and missing crossorigin on preconnect. * fix(validate): handle dual preconnect pattern (CORS + non-CORS) Preconnects with and without crossorigin establish separate connection pools and are intentionally paired. Include crossorigin in the dedupe key for duplicate-resource-hint, and skip preconnect-missing-crossorigin when a CORS preconnect already exists for the same origin.
1 parent 8de2266 commit 093a277

3 files changed

Lines changed: 302 additions & 1 deletion

File tree

docs/head/1.guides/plugins/validate.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: "Validate"
3-
description: "Catch common SEO and head tag mistakes. Validates URLs, meta tags, Open Graph, Twitter Cards, and detects typos with fuzzy matching."
3+
description: "Catch common SEO, performance, and head tag mistakes. Validates URLs, meta tags, Open Graph, Twitter Cards, web vitals anti-patterns, and detects typos with fuzzy matching."
44
navigation.title: "Validate"
55
---
66

@@ -130,6 +130,13 @@ Rules inspired by [webperf-snippets](https://webperf-snippets.nucliweb.net/) tha
130130

131131
| Rule ID | Severity | What it catches |
132132
|---------|----------|----------------|
133+
| `render-blocking-script` | `warn` | `<script src>` in head without `async`, `defer`, or `type="module"` blocks the critical rendering path |
134+
| `too-many-fetchpriority-high` | `warn` | More than 2 resources with `fetchpriority="high"`. When everything is high priority, nothing is |
135+
| `defer-on-module-script` | `info` | `defer` on a `type="module"` script is redundant. Modules are deferred by default |
136+
| `duplicate-resource-hint` | `warn` | Same `rel`/`href` pair appears multiple times in preload, prefetch, or preconnect tags |
137+
| `charset-not-early` | `warn` | `<meta charset>` is not within the first few tags in `<head>`, which can force the browser to re-parse |
138+
| `preload-not-modulepreload` | `warn` | `<link rel="preload" as="script">` for a module script should use `rel="modulepreload"` to also trigger module parsing |
139+
| `preconnect-missing-crossorigin` | `warn` | `<link rel="preconnect">` is missing `crossorigin` but CORS resources are loaded from that origin, causing a separate connection |
133140
| `preload-fetchpriority-conflict` | `warn` | `<link rel="preload" fetchpriority="low">` is contradictory — preload signals critical, low priority contradicts that |
134141
| `too-many-preloads` | `warn` | More than 6 `<link rel="preload">` tags compete for bandwidth and hurt performance |
135142
| `too-many-preconnects` | `warn` | More than 4 `<link rel="preconnect">` tags — each initiates a TCP+TLS handshake, competing for limited connections |
@@ -167,6 +174,8 @@ ValidatePlugin({
167174
rules: {
168175
'too-many-preloads': ['warn', { max: 10 }],
169176
'too-many-preconnects': ['warn', { max: 6 }],
177+
'too-many-fetchpriority-high': ['warn', { max: 3 }],
178+
'charset-not-early': ['warn', { maxPosition: 5 }],
170179
'inline-style-size': ['info', { maxKB: 20 }],
171180
'inline-script-size': ['info', { maxKB: 5 }],
172181
'meta-beyond-1mb': ['warn', { maxBytes: 512_000 }], // 500KB instead of default 1MB

packages/unhead/src/plugins/validate.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ export type RuleSeverity = 'warn' | 'info' | 'off'
55

66
export type ValidationRuleId
77
= | 'canonical-og-url-mismatch'
8+
| 'charset-not-early'
9+
| 'defer-on-module-script'
810
| 'deprecated-option-mode'
911
| 'deprecated-prop-body'
1012
| 'deprecated-prop-children'
1113
| 'deprecated-prop-hid-vmid'
14+
| 'duplicate-resource-hint'
1215
| 'empty-meta-content'
1316
| 'empty-title'
1417
| 'html-in-title'
@@ -25,24 +28,30 @@ export type ValidationRuleId
2528
| 'og-missing-description'
2629
| 'og-missing-title'
2730
| 'possible-typo'
31+
| 'preconnect-missing-crossorigin'
2832
| 'prefetch-preload-conflict'
2933
| 'preload-async-defer-conflict'
3034
| 'preload-fetchpriority-conflict'
3135
| 'preload-font-crossorigin'
3236
| 'preload-missing-as'
37+
| 'preload-not-modulepreload'
3338
| 'redundant-dns-prefetch'
39+
| 'render-blocking-script'
3440
| 'robots-conflict'
3541
| 'script-src-with-content'
42+
| 'too-many-fetchpriority-high'
3643
| 'too-many-preconnects'
3744
| 'too-many-preloads'
3845
| 'twitter-handle-missing-at'
3946
| 'unresolved-template-param'
4047
| 'viewport-user-scalable'
4148

4249
export interface ValidationRuleOptions {
50+
'charset-not-early': { maxPosition: number }
4351
'inline-script-size': { maxKB: number }
4452
'inline-style-size': { maxKB: number }
4553
'meta-beyond-1mb': { maxBytes: number }
54+
'too-many-fetchpriority-high': { max: number }
4655
'too-many-preloads': { max: number }
4756
'too-many-preconnects': { max: number }
4857
}
@@ -246,6 +255,13 @@ function isAbsoluteUrl(url: string): boolean {
246255
return url.startsWith('http://') || url.startsWith('https://')
247256
}
248257

258+
function extractOrigin(url: string): string | undefined {
259+
if (!isAbsoluteUrl(url))
260+
return undefined
261+
const slash = url.indexOf('/', url.indexOf('//') + 2)
262+
return slash === -1 ? url : url.slice(0, slash)
263+
}
264+
249265
function resolveSeverity(config: RuleSeverity | [RuleSeverity, unknown] | undefined, fallback: RuleSeverity): RuleSeverity {
250266
if (config == null)
251267
return fallback
@@ -443,6 +459,14 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) {
443459
if (tag.tag === 'script' && props.src && (tag.innerHTML || tag.textContent))
444460
report('script-src-with-content', `Script has both "src" and inline content — the browser will ignore the inline content.`, 'warn', tag)
445461

462+
// Render-blocking script in head without async/defer/module
463+
if (tag.tag === 'script' && props.src && !props.async && !props.defer && props.type !== 'module' && (!tag.tagPosition || tag.tagPosition === 'head'))
464+
report('render-blocking-script', `Script "${props.src}" is render-blocking. Add "async", "defer", or use type="module" to avoid blocking the critical rendering path.`, 'warn', tag)
465+
466+
// defer on module scripts is redundant
467+
if (tag.tag === 'script' && props.type === 'module' && props.defer)
468+
report('defer-on-module-script', `"defer" is redundant on module scripts. Modules are deferred by default.`, 'info', tag)
469+
446470
// === Performance Hints ===
447471
// Inspired by webperf-snippets (https://webperf-snippets.nucliweb.net/)
448472

@@ -583,6 +607,76 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) {
583607
report('deprecated-prop-body', `"body: true" was removed in v3. Use "tagPosition: 'bodyClose'" instead.`, 'warn', tag)
584608
}
585609

610+
// Too many fetchpriority="high" dilutes the signal
611+
const { max: maxHighPriority } = resolveOptions(ruleConfig, 'too-many-fetchpriority-high', { max: 2 })
612+
const highPriorityCount = tags.filter((t: HeadTag) => t.props.fetchpriority === 'high').length
613+
if (highPriorityCount > maxHighPriority)
614+
report('too-many-fetchpriority-high', `Found ${highPriorityCount} resources with fetchpriority="high". When everything is high priority, nothing is. Limit to ${maxHighPriority} for the signal to be effective.`, 'warn')
615+
616+
// Duplicate resource hints (same href in multiple preload/preconnect/prefetch)
617+
const resourceHintsSeen = new Map<string, HeadTag>()
618+
for (const tag of tags) {
619+
if (tag.tag === 'link' && tag.props.href && (tag.props.rel === 'preload' || tag.props.rel === 'prefetch' || tag.props.rel === 'preconnect')) {
620+
const crossoriginSuffix = tag.props.rel === 'preconnect' && 'crossorigin' in tag.props ? ':cors' : ''
621+
const key = `${tag.props.rel}:${tag.props.href}${crossoriginSuffix}`
622+
if (resourceHintsSeen.has(key))
623+
report('duplicate-resource-hint', `Duplicate ${tag.props.rel} for "${tag.props.href}".`, 'warn', tag)
624+
else
625+
resourceHintsSeen.set(key, tag)
626+
}
627+
}
628+
629+
// 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
642+
}
643+
}
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)
646+
647+
// preload as="script" when the actual script is type="module" should use modulepreload
648+
const moduleScriptSrcs = new Set<string>()
649+
for (const tag of tags) {
650+
if (tag.tag === 'script' && tag.props.type === 'module' && tag.props.src)
651+
moduleScriptSrcs.add(tag.props.src)
652+
}
653+
for (const tag of tags) {
654+
if (tag.tag === 'link' && tag.props.rel === 'preload' && tag.props.as === 'script' && tag.props.href && moduleScriptSrcs.has(tag.props.href))
655+
report('preload-not-modulepreload', `"${tag.props.href}" is a module script but uses rel="preload". Use rel="modulepreload" instead to also trigger module parsing.`, 'warn', tag)
656+
}
657+
658+
// Preconnect missing crossorigin for origins that serve CORS resources
659+
const corsOrigins = new Set<string>()
660+
const preconnectCorsOrigins = new Set<string>()
661+
for (const tag of tags) {
662+
if (tag.tag === 'link' && tag.props.href && 'crossorigin' in tag.props) {
663+
const origin = extractOrigin(tag.props.href)
664+
if (origin) {
665+
corsOrigins.add(origin)
666+
if (tag.props.rel === 'preconnect')
667+
preconnectCorsOrigins.add(origin)
668+
}
669+
}
670+
}
671+
for (const tag of tags) {
672+
if (tag.tag === 'link' && tag.props.rel === 'preconnect' && tag.props.href && !('crossorigin' in tag.props)) {
673+
const origin = extractOrigin(tag.props.href)
674+
// Skip if a CORS preconnect already exists for this origin (intentional dual connection pool)
675+
if (origin && corsOrigins.has(origin) && !preconnectCorsOrigins.has(origin))
676+
report('preconnect-missing-crossorigin', `Preconnect to "${tag.props.href}" is missing "crossorigin" but CORS resources are loaded from this origin. Without it, the browser opens a separate connection for CORS requests.`, 'warn', tag)
677+
}
678+
}
679+
586680
// Meta tags rendered after the crawler byte limit (default 1MB)
587681
// Social crawlers (Facebook, Twitter) only parse the first ~1MB of HTML.
588682
// Large inline styles can push SEO/OG meta tags past this limit.

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

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,204 @@ describe('validatePlugin', () => {
868868
renderSSRHead(head)
869869
expect(rules.find(r => r.id === 'meta-beyond-1mb')).toBeFalsy()
870870
})
871+
872+
it('warns on render-blocking script in head', () => {
873+
const { head, rules } = createValidationHead()
874+
head.push({
875+
script: [{ src: '/app.js' }],
876+
})
877+
renderSSRHead(head)
878+
expect(rules.find(r => r.id === 'render-blocking-script')).toBeTruthy()
879+
})
880+
881+
it('does not warn on async script', () => {
882+
const { head, rules } = createValidationHead()
883+
head.push({
884+
script: [{ src: '/app.js', async: true }],
885+
})
886+
renderSSRHead(head)
887+
expect(rules.find(r => r.id === 'render-blocking-script')).toBeFalsy()
888+
})
889+
890+
it('does not warn on defer script', () => {
891+
const { head, rules } = createValidationHead()
892+
head.push({
893+
script: [{ src: '/app.js', defer: true }],
894+
})
895+
renderSSRHead(head)
896+
expect(rules.find(r => r.id === 'render-blocking-script')).toBeFalsy()
897+
})
898+
899+
it('does not warn on module script', () => {
900+
const { head, rules } = createValidationHead()
901+
head.push({
902+
script: [{ src: '/app.js', type: 'module' }],
903+
})
904+
renderSSRHead(head)
905+
expect(rules.find(r => r.id === 'render-blocking-script')).toBeFalsy()
906+
})
907+
908+
it('warns on defer with module script (redundant)', () => {
909+
const { head, rules } = createValidationHead()
910+
head.push({
911+
script: [{ src: '/app.js', type: 'module', defer: true }],
912+
})
913+
renderSSRHead(head)
914+
expect(rules.find(r => r.id === 'defer-on-module-script')).toBeTruthy()
915+
})
916+
917+
it('does not warn on module script without defer', () => {
918+
const { head, rules } = createValidationHead()
919+
head.push({
920+
script: [{ src: '/app.js', type: 'module' }],
921+
})
922+
renderSSRHead(head)
923+
expect(rules.find(r => r.id === 'defer-on-module-script')).toBeFalsy()
924+
})
925+
926+
it('warns on too many fetchpriority="high"', () => {
927+
const { head, rules } = createValidationHead()
928+
head.push({
929+
link: [
930+
{ rel: 'preload', href: '/a.js', as: 'script' as const, fetchpriority: 'high' as const },
931+
{ rel: 'preload', href: '/b.js', as: 'script' as const, fetchpriority: 'high' as const },
932+
{ rel: 'preload', href: '/c.js', as: 'script' as const, fetchpriority: 'high' as const },
933+
],
934+
})
935+
renderSSRHead(head)
936+
expect(rules.find(r => r.id === 'too-many-fetchpriority-high')).toBeTruthy()
937+
})
938+
939+
it('does not warn on 2 or fewer fetchpriority="high"', () => {
940+
const { head, rules } = createValidationHead()
941+
head.push({
942+
link: [
943+
{ rel: 'preload', href: '/a.js', as: 'script' as const, fetchpriority: 'high' as const },
944+
{ rel: 'preload', href: '/b.js', as: 'script' as const, fetchpriority: 'high' as const },
945+
],
946+
})
947+
renderSSRHead(head)
948+
expect(rules.find(r => r.id === 'too-many-fetchpriority-high')).toBeFalsy()
949+
})
950+
951+
it('does not warn on preconnect with and without crossorigin (different connection pools)', () => {
952+
const { head, rules } = createValidationHead()
953+
// Non-CORS and CORS preconnects establish separate connection pools, both are intentional
954+
head.push({
955+
link: [
956+
{ rel: 'preconnect', href: 'https://cdn.example.com' },
957+
{ rel: 'preconnect', href: 'https://cdn.example.com', crossorigin: 'anonymous' },
958+
],
959+
})
960+
renderSSRHead(head)
961+
expect(rules.find(r => r.id === 'duplicate-resource-hint')).toBeFalsy()
962+
})
963+
964+
it('warns on duplicate prefetch for same href (different keys bypass dedup)', () => {
965+
const { head, rules } = createValidationHead()
966+
head.push({
967+
link: [
968+
{ rel: 'prefetch' as const, href: '/next-page.js', key: 'pf1' },
969+
{ rel: 'prefetch' as const, href: '/next-page.js', key: 'pf2' },
970+
],
971+
})
972+
renderSSRHead(head)
973+
expect(rules.find(r => r.id === 'duplicate-resource-hint')).toBeTruthy()
974+
})
975+
976+
it('does not warn on different preload hrefs', () => {
977+
const { head, rules } = createValidationHead()
978+
head.push({
979+
link: [
980+
{ rel: 'preload', href: '/a.woff2', as: 'font' as const, crossorigin: 'anonymous' },
981+
{ rel: 'preload', href: '/b.woff2', as: 'font' as const, crossorigin: 'anonymous' },
982+
],
983+
})
984+
renderSSRHead(head)
985+
expect(rules.find(r => r.id === 'duplicate-resource-hint')).toBeFalsy()
986+
})
987+
988+
it('warns when charset is not early in head', () => {
989+
const { head, rules } = createValidationHead({ rules: { 'charset-not-early': ['warn', { maxPosition: 1 }] } })
990+
// Push CSP meta which sorts before charset (weight -30 vs -20)
991+
head.push({
992+
meta: [{ 'http-equiv': 'content-security-policy', 'content': 'default-src \'self\'' }],
993+
})
994+
head.push({
995+
meta: [{ charset: 'utf-8' }],
996+
})
997+
renderSSRHead(head)
998+
expect(rules.find(r => r.id === 'charset-not-early')).toBeTruthy()
999+
})
1000+
1001+
it('does not warn when charset is within maxPosition', () => {
1002+
const { head, rules } = createValidationHead()
1003+
head.push({
1004+
meta: [{ charset: 'utf-8' }],
1005+
})
1006+
head.push({
1007+
style: [{ innerHTML: 'body { color: red }' }],
1008+
})
1009+
renderSSRHead(head)
1010+
expect(rules.find(r => r.id === 'charset-not-early')).toBeFalsy()
1011+
})
1012+
1013+
it('warns on preload as="script" for module script (should use modulepreload)', () => {
1014+
const { head, rules } = createValidationHead()
1015+
head.push({
1016+
link: [{ rel: 'preload', href: '/app.mjs', as: 'script' as const }],
1017+
script: [{ src: '/app.mjs', type: 'module' }],
1018+
})
1019+
renderSSRHead(head)
1020+
expect(rules.find(r => r.id === 'preload-not-modulepreload')).toBeTruthy()
1021+
})
1022+
1023+
it('does not warn on preload for non-module script', () => {
1024+
const { head, rules } = createValidationHead()
1025+
head.push({
1026+
link: [{ rel: 'preload', href: '/app.js', as: 'script' as const }],
1027+
script: [{ src: '/app.js' }],
1028+
})
1029+
renderSSRHead(head)
1030+
expect(rules.find(r => r.id === 'preload-not-modulepreload')).toBeFalsy()
1031+
})
1032+
1033+
it('warns on preconnect missing crossorigin when CORS resources use same origin', () => {
1034+
const { head, rules } = createValidationHead()
1035+
head.push({
1036+
link: [
1037+
{ rel: 'preconnect', href: 'https://fonts.gstatic.com' },
1038+
{ rel: 'preload', href: 'https://fonts.gstatic.com/s/roboto.woff2', as: 'font' as const, crossorigin: 'anonymous' },
1039+
],
1040+
})
1041+
renderSSRHead(head)
1042+
expect(rules.find(r => r.id === 'preconnect-missing-crossorigin')).toBeTruthy()
1043+
})
1044+
1045+
it('does not warn on preconnect with crossorigin', () => {
1046+
const { head, rules } = createValidationHead()
1047+
head.push({
1048+
link: [
1049+
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: 'anonymous' },
1050+
{ rel: 'preload', href: 'https://fonts.gstatic.com/s/roboto.woff2', as: 'font' as const, crossorigin: 'anonymous' },
1051+
],
1052+
})
1053+
renderSSRHead(head)
1054+
expect(rules.find(r => r.id === 'preconnect-missing-crossorigin')).toBeFalsy()
1055+
})
1056+
1057+
it('does not warn on intentional dual preconnect (CORS + non-CORS)', () => {
1058+
const { head, rules } = createValidationHead()
1059+
head.push({
1060+
link: [
1061+
{ rel: 'preconnect', href: 'https://cdn.example.com' },
1062+
{ rel: 'preconnect', href: 'https://cdn.example.com', crossorigin: 'anonymous' },
1063+
{ rel: 'preload', href: 'https://cdn.example.com/font.woff2', as: 'font' as const, crossorigin: 'anonymous' },
1064+
],
1065+
})
1066+
renderSSRHead(head)
1067+
expect(rules.find(r => r.id === 'preconnect-missing-crossorigin')).toBeFalsy()
1068+
})
8711069
})
8721070

8731071
describe('v2 migration', () => {

0 commit comments

Comments
 (0)