Skip to content

Commit a07782d

Browse files
committed
Merge remote-tracking branch 'origin/main' into perf/optional-oxc-parser
# Conflicts: # packages/bundler/src/unplugin/MinifyTransform.ts
2 parents 026fe44 + 665dcdc commit a07782d

51 files changed

Lines changed: 1789 additions & 118 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/bundle-size.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,21 @@ jobs:
3232
cache: pnpm
3333

3434
- name: Build base branch bundles
35+
env:
36+
BASE_REF: ${{ github.base_ref }}
3537
run: |
3638
# the PR's perf harness may not exist on the base ref; keep a copy so we can
3739
# measure the base build with the same script (same runner cancels variance)
3840
cp bench/perf-ci.mjs /tmp/perf-ci.mjs
3941
cp bench/bundle/dependency-analyze.mjs /tmp/dependency-analyze.mjs
42+
cp bench/unplugin-transform.bench.ts /tmp/unplugin-transform.bench.ts
4043
# runner setup (pnpm store probing) can leave the lockfile dirty; nothing
4144
# in this fresh checkout is precious, so surface the diff and discard it
4245
git status --porcelain
4346
git diff --stat
44-
git checkout -f ${{ github.base_ref }}
47+
git checkout -f "$BASE_REF"
4548
pnpm install
49+
cp /tmp/unplugin-transform.bench.ts bench/unplugin-transform.bench.ts
4650
node /tmp/dependency-analyze.mjs > /tmp/base-dependencies.json
4751
pnpm build
4852
# the base ref may predate some bundle configs (react, full, schema-org);
@@ -58,6 +62,7 @@ jobs:
5862
# measure base perf with the PR's harness against the base-built packages;
5963
# run from /tmp so the working tree stays clean across the checkout back
6064
node --expose-gc /tmp/perf-ci.mjs > /tmp/base-perf.json || echo '{}' > /tmp/base-perf.json
65+
pnpm exec vitest bench bench/unplugin-transform.bench.ts --config bench/vitest.config.ts --run --outputJson /tmp/base-transform-perf.json
6166
# the base pnpm install may have rewritten the lockfile; discard before switching back
6267
git checkout -f -
6368
@@ -71,6 +76,7 @@ jobs:
7176
pnpm test:react-bundle-size
7277
pnpm test:schema-org-bundle-size
7378
node --expose-gc bench/perf-ci.mjs > /tmp/pr-perf.json || echo '{}' > /tmp/pr-perf.json
79+
pnpm exec vitest bench bench/unplugin-transform.bench.ts --config bench/vitest.config.ts --run --outputJson /tmp/pr-transform-perf.json
7480
7581
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
7682

@@ -86,8 +92,10 @@ jobs:
8692
BASE_DIST: /tmp/base-dist
8793
BASE_DEPENDENCIES: /tmp/base-dependencies.json
8894
BASE_PERF: /tmp/base-perf.json
95+
BASE_TRANSFORM_PERF: /tmp/base-transform-perf.json
8996
PR_DEPENDENCIES: /tmp/pr-dependencies.json
9097
PR_PERF: /tmp/pr-perf.json
98+
PR_TRANSFORM_PERF: /tmp/pr-transform-perf.json
9199
run: |
92100
output=$(bun bench/bundle/report.ts)
93101
echo "$output"

bench/bundle/last.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,17 @@
3232
"reactClient": {
3333
"size": 15073,
3434
"gz": 5988,
35-
"br": 5426
35+
"br": 5431
3636
},
3737
"reactClientFull": {
3838
"size": 28729,
39-
"gz": 10878,
40-
"br": 9835
39+
"gz": 10877,
40+
"br": 9836
4141
},
4242
"reactServer": {
4343
"size": 13327,
4444
"gz": 5314,
45-
"br": 4800
45+
"br": 4802
4646
},
4747
"schemaOrg": {
4848
"size": 28631,

bench/bundle/perf-report.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,47 @@
11
import { describe, expect, it } from 'vitest'
2-
import { renderPerfReport } from './perf-report'
2+
import { parseVitestBenchmarks, renderPerfReport } from './perf-report'
3+
4+
describe('parseVitestBenchmarks', () => {
5+
it('treats a missing benchmark file as an empty run', () => {
6+
expect(parseVitestBenchmarks(null)).toEqual({ benches: [] })
7+
})
8+
9+
it('rejects malformed benchmark output', () => {
10+
expect(() => parseVitestBenchmarks({
11+
files: [{
12+
groups: [{
13+
benchmarks: [{
14+
name: 'useSeoMetaTransform static calls',
15+
mean: '1.25',
16+
rme: 2.5,
17+
}],
18+
}],
19+
}],
20+
})).toThrowError('Invalid Vitest benchmark result')
21+
})
22+
23+
it('converts transform benchmark output into performance benches', () => {
24+
expect(parseVitestBenchmarks({
25+
files: [{
26+
groups: [{
27+
benchmarks: [{
28+
name: 'useSeoMetaTransform static calls',
29+
mean: 1.25,
30+
rme: 2.5,
31+
}],
32+
}],
33+
}],
34+
})).toEqual({
35+
benches: [{
36+
id: 'bundler-transform:useSeoMetaTransform static calls',
37+
name: 'Bundler: useSeoMetaTransform static calls',
38+
kind: 'time',
39+
value: 1.25,
40+
rme: 2.5,
41+
}],
42+
})
43+
})
44+
})
345

446
describe('renderPerfReport allocation noise gate', () => {
547
it('keeps allocation changes within the combined RME out of the verdict', () => {

bench/bundle/perf-report.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,54 @@ export interface PerfRun {
2323
benches: PerfBench[]
2424
}
2525

26+
function isRecord(value: unknown): value is Record<string, unknown> {
27+
return typeof value === 'object' && value !== null
28+
}
29+
30+
function parseVitestBenchmark(value: unknown): PerfBench {
31+
if (
32+
!isRecord(value)
33+
|| typeof value.name !== 'string'
34+
|| typeof value.mean !== 'number'
35+
|| !Number.isFinite(value.mean)
36+
|| typeof value.rme !== 'number'
37+
|| !Number.isFinite(value.rme)
38+
) {
39+
throw new TypeError('Invalid Vitest benchmark result')
40+
}
41+
42+
return {
43+
id: `bundler-transform:${value.name}`,
44+
name: `Bundler: ${value.name}`,
45+
kind: 'time',
46+
value: value.mean,
47+
rme: value.rme,
48+
}
49+
}
50+
51+
export function parseVitestBenchmarks(value: unknown): PerfRun {
52+
if (value === null || value === undefined)
53+
return { benches: [] }
54+
55+
if (!isRecord(value) || !Array.isArray(value.files))
56+
throw new TypeError('Invalid Vitest benchmark output')
57+
58+
const benches = value.files.flatMap((file) => {
59+
if (!isRecord(file) || !Array.isArray(file.groups))
60+
throw new TypeError('Invalid Vitest benchmark file')
61+
return file.groups.flatMap((group) => {
62+
if (!isRecord(group) || !Array.isArray(group.benchmarks))
63+
throw new TypeError('Invalid Vitest benchmark group')
64+
return group.benchmarks.map(parseVitestBenchmark)
65+
})
66+
})
67+
68+
if (!benches.length)
69+
throw new TypeError('Vitest benchmark output contained no results')
70+
71+
return { benches }
72+
}
73+
2674
const TIME_FLOOR_PCT = 5
2775
const ALLOC_FLOOR_PCT = 2
2876
const ALLOC_FLOOR_BYTES = 1024

bench/bundle/report.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import type { PerfRun } from './perf-report'
12
import fs from 'node:fs'
23
import process from 'node:process'
34
import { collectBundleData, renderBundleReport } from './bundle-report'
45
import { renderDependencyReport } from './dependency-report'
5-
import { renderPerfReport } from './perf-report'
6+
import { parseVitestBenchmarks, renderPerfReport } from './perf-report'
67

78
// Combined bundle, dependency, and perf comment for the PR. Bundle data comes
89
// from the dist dirs; dependency and perf data come from JSON generated for the
@@ -17,10 +18,22 @@ const prDependencies = readJson(process.env.PR_DEPENDENCIES)
1718
if (prDependencies?.packages?.length)
1819
sections.push(renderDependencyReport(readJson(process.env.BASE_DEPENDENCIES), prDependencies))
1920

21+
function mergePerfRuns(...runs: Array<PerfRun | null>): PerfRun | null {
22+
const benches = runs.flatMap(run => run?.benches || [])
23+
return benches.length ? { benches } : null
24+
}
25+
2026
// guard on benches: a perf run that failed writes `{}`, which must skip the section, not crash
21-
const prPerf = readJson(process.env.PR_PERF)
27+
const basePerf = mergePerfRuns(
28+
readJson(process.env.BASE_PERF),
29+
parseVitestBenchmarks(readJson(process.env.BASE_TRANSFORM_PERF)),
30+
)
31+
const prPerf = mergePerfRuns(
32+
readJson(process.env.PR_PERF),
33+
parseVitestBenchmarks(readJson(process.env.PR_TRANSFORM_PERF)),
34+
)
2235
if (prPerf?.benches?.length)
23-
sections.push(renderPerfReport(readJson(process.env.BASE_PERF), prPerf))
36+
sections.push(renderPerfReport(basePerf, prPerf))
2437

2538
let out = sections.join('\n\n---\n\n')
2639

bench/unplugin-transform.bench.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,21 @@ async function runPluginTransform(plugin: any, code: string, id: string, context
9393
return await transformHandler(plugin).call(context, code, id)
9494
}
9595

96+
function assertTransformResult(result: unknown, name: string) {
97+
if (
98+
typeof result === 'string'
99+
|| (
100+
typeof result === 'object'
101+
&& result !== null
102+
&& 'code' in result
103+
&& typeof (result as { code?: unknown }).code === 'string'
104+
)
105+
) {
106+
return
107+
}
108+
throw new TypeError(`${name} benchmark did not transform its fixture`)
109+
}
110+
96111
describe('unplugin transform CPU', () => {
97112
bench('transformInclude mixed ids', () => {
98113
const seo = UseSeoMetaTransform.vite({}) as any
@@ -112,17 +127,25 @@ describe('unplugin transform CPU', () => {
112127

113128
bench('useSeoMetaTransform static calls', async () => {
114129
const plugin = UseSeoMetaTransform.vite({}) as any
115-
await runPluginTransform(plugin, seoCode, '/project/src/page.ts')
130+
const result = await runPluginTransform(plugin, seoCode, '/project/src/page.ts')
131+
assertTransformResult(result, plugin.name)
116132
})
117133

118134
bench('minifyTransform inline script/style', async () => {
119135
const plugin = MinifyTransform.vite({ js: mockJSMinifier, css: mockCSSMinifier }) as any
120-
await runPluginTransform(plugin, minifyCode, '/project/src/page.ts')
136+
const result = await runPluginTransform(plugin, minifyCode, '/project/src/page.ts')
137+
assertTransformResult(result, plugin.name)
121138
})
122139

123140
bench('treeshakeServerComposables many calls', async () => {
124141
const plugin = TreeshakeServerComposables.vite({}) as any
125-
await runPluginTransform(plugin, treeshakeCode, '/project/src/page.ts')
142+
const result = await runPluginTransform(
143+
plugin,
144+
treeshakeCode,
145+
'/project/src/page.ts',
146+
{ environment: { config: { consumer: 'client' } } },
147+
)
148+
assertTransformResult(result, plugin.name)
126149
})
127150

128151
bench('treeshakeServerComposables skip unrelated code', async () => {
@@ -133,7 +156,8 @@ describe('unplugin transform CPU', () => {
133156
bench('ssrStaticReplace many head.ssr reads', async () => {
134157
const plugin = SSRStaticReplace.vite({}) as any
135158
plugin.apply({}, { command: 'build', isSsrBuild: false })
136-
await runPluginTransform(plugin, ssrStaticReplaceCode, '/project/node_modules/unhead/dist/index.mjs')
159+
const result = await runPluginTransform(plugin, ssrStaticReplaceCode, '/project/node_modules/unhead/dist/index.mjs')
160+
assertTransformResult(result, plugin.name)
137161
})
138162

139163
bench('ssrStaticReplace skip unrelated code', async () => {
@@ -154,7 +178,8 @@ describe('unplugin transform CPU', () => {
154178
})
155179
const plugin = CreateHeadTransform(ctx) as any
156180
plugin.configResolved({ root: '/project' })
157-
await plugin.transform.handler.call({ environment: { config: { consumer: 'client' } } }, createHeadCode, '/project/src/head.ts')
181+
const result = await plugin.transform.handler.call({ environment: { config: { consumer: 'client' } } }, createHeadCode, '/project/src/head.ts')
182+
assertTransformResult(result, plugin.name)
158183
})
159184

160185
bench('react streaming skip JSX without head calls', async () => {
@@ -164,7 +189,8 @@ describe('unplugin transform CPU', () => {
164189

165190
bench('react streaming transform JSX with head calls', async () => {
166191
const plugin = unheadReactStreamingPlugin.vite({}) as any
167-
await plugin.transform.handler.call({ environment: { name: 'client' } }, jsxWithHeadCode, '/project/src/page.tsx')
192+
const result = await plugin.transform.handler.call({ environment: { name: 'client' } }, jsxWithHeadCode, '/project/src/page.tsx')
193+
assertTransformResult(result, plugin.name)
168194
})
169195

170196
bench('solid streaming skip JSX without head calls', async () => {

docs/0.react/head/guides/1.core-concepts/1.components.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,24 @@ Google does not set a fixed meta-description length; search snippets are truncat
8080
</Head>
8181
```
8282

83+
### Raw HTML
84+
85+
`<Head>` supports React's `dangerouslySetInnerHTML` prop on `<title>`, `<script>`, `<style>`, and `<noscript>`. It cannot be combined with children.
86+
87+
Use a string child for ordinary inline scripts and styles. Use `dangerouslySetInnerHTML` when the content must be parsed as raw markup, such as an HTML fallback inside `<noscript>`:
88+
89+
```tsx
90+
const trustedFallback = {
91+
__html: '<img src="/pixel.gif" alt="">'
92+
}
93+
94+
<Head>
95+
<noscript dangerouslySetInnerHTML={trustedFallback} />
96+
</Head>
97+
```
98+
99+
Only pass trusted or sanitized content. Untrusted HTML can introduce an XSS vulnerability. See [React's `dangerouslySetInnerHTML` guidance](https://react.dev/reference/react-dom/components/common#dangerously-setting-the-inner-html).
100+
83101
### Open Graph
84102

85103
```tsx

docs/0.typescript/head/guides/2.tooling/0.eslint-plugin.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,23 +46,26 @@ export default [
4646

4747
## Rules
4848

49-
The plugin ships 14 rules. The full table with severity, autofix status, and what each rule catches lives in the package README:
49+
The plugin ships 15 rules. The full table with severity, autofix status, and what each rule catches lives in the package README:
5050

5151
[`@unhead/eslint-plugin` rules table](https://github.com/unjs/unhead/tree/main/packages/eslint-plugin#rules)
5252

5353
Migration rules include:
5454

5555
- `no-deprecated-props` (error, autofix): rewrites v2 property names that v3 no longer converts.
5656
- `no-unknown-meta` (warn, autofix): suggests corrections for typos such as `og:descriptin``og:description`.
57+
- `invalid-input-shape` (warn): catches wrong container shapes and head fields such as `meta` or `titleTemplate` nested inside `htmlAttrs` or `bodyAttrs`.
5758
- `numeric-tag-priority` (warn, suggestions): flags numeric `tagPriority` values and suggests `'critical' | 'high' | 'low'`.
5859
- `prefer-define-helpers` (off in `recommended`, on in `migration`, autofix): wraps `link` and `script` literals in `defineLink` and `defineScript` so Unhead's discriminated unions narrow correctly.
5960

6061
## What it can and can't see
6162

62-
Lint rules walk source-level calls into `useHead`, `useHeadSafe`, `useServerHead`, `useServerHeadSafe`, `useSeoMeta`, `useServerSeoMeta`, and the tag helpers `defineLink` / `defineScript`. Tag arrays inside `meta` / `link` / `script` / `noscript` / `style` keys are descended automatically.
63+
Lint rules walk source-level calls into `useHead`, `useHeadSafe`, `useServerHead`, `useServerHeadSafe`, `useSeoMeta`, `useServerSeoMeta`, and the tag helpers `defineLink` / `defineScript`. Tag arrays inside `meta` / `link` / `script` / `noscript` / `style` and object literals inside `htmlAttrs` / `bodyAttrs` are descended automatically.
6364

6465
It cannot see anything that depends on the resolved tag set: cross-tag conflicts (canonical vs `og:url`), counts (too many preloads), or rendered byte budgets (meta beyond 1MB). Those checks live in the runtime [`ValidatePlugin`](/docs/typescript/head/guides/tooling/validate-plugin) and are surfaced by the [CLI](/docs/typescript/head/guides/tooling/cli)'s `validate-html` and `validate-url` commands.
6566

67+
Calls, identifiers, refs, and computed expressions have an unknown static shape, so this rule leaves them for runtime validation.
68+
6669
## Editor integration
6770

6871
Editors that run ESLint through the VS Code or JetBrains integration expose fixes for autofixable rules. The `numeric-tag-priority` rule also offers a suggestion for each priority alias.

0 commit comments

Comments
 (0)