Skip to content

perf(unhead): scale duplicate tag resolution - #945

Open
harlan-zw wants to merge 6 commits into
perf/ssr-duplicate-benchmarkfrom
perf/ssr-dedupe-hot-path
Open

perf(unhead): scale duplicate tag resolution#945
harlan-zw wants to merge 6 commits into
perf/ssr-duplicate-benchmarkfrom
perf/ssr-dedupe-hot-path

Conversation

@harlan-zw

@harlan-zw harlan-zw commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

🔗 Linked issue

Stacked on #943.

❓ Type of change

  • 📖 Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

Repeated merge tags copied a growing props object for every duplicate. Arrayable metadata also copied its growing accumulator.

Reuse render-owned accumulators after one copy. Keep cached entry props immutable, and reject unsafe inherited keys while merging.

The benchmark in #943 measures 100 merged htmlAttrs entries and 100 arrayable og:image values.

Summary by CodeRabbit

  • Bug Fixes

    • Improved metadata and attribute merging across repeated renders, including array-based and iterable values.
    • Preserved valid class, style, and language attributes while preventing unsafe properties from being rendered.
    • Prevented cached metadata from being unintentionally modified during deduplication.
    • Added protection against prototype-polluting attributes.
  • Performance

    • Added coverage for duplicate-heavy server rendering scenarios.
    • Improved performance change detection with confidence-aware thresholds and clearer percentage reporting.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 625fb20e-b2e5-449a-97f4-b4ac4a083060

📥 Commits

Reviewing files that changed from the base of the PR and between e6a4f46 and be237ef.

📒 Files selected for processing (2)
  • packages/unhead/src/utils/resolve.ts
  • packages/unhead/test/unit/server/deduping.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/unhead/src/utils/resolve.ts
  • packages/unhead/test/unit/server/deduping.test.ts

📝 Walkthrough

Walkthrough

The PR adds a duplicate-heavy SSR benchmark, changes performance significance calculations to use confidence intervals, propagates benchmark failures in CI, and updates tag deduplication to clone merged values and reject unsafe properties.

Changes

Performance benchmark reporting

Layer / File(s) Summary
Benchmark coverage and noise classification
bench/perf-ci.mjs, bench/bundle/perf-report.ts, bench/bundle/perf-report.test.ts
The benchmark measures duplicate-heavy cached SSR performance. Reports classify time and allocation changes with combined confidence intervals. Informational metrics display percentage deltas.
Benchmark failure propagation
.github/workflows/bundle-size.yml
Base and pull request performance commands now propagate failures instead of writing empty results.

Tag deduplication safety

Layer / File(s) Summary
Safe tag cloning and merging
packages/unhead/src/utils/resolve.ts
Tag cloning now copies merge containers. Deduplication preserves merged arrays, handles iterable class and style values, avoids cache mutation, and filters unsafe property names.
Deduplication regression coverage
packages/unhead/test/unit/server/deduping.test.ts, packages/unhead/test/unit/server/prototype-pollution.test.ts
Server tests cover repeated metadata, replacement behavior, cumulative attribute merging, iterable values, and unsafe-property filtering.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DuplicateHeavyRenderer
  participant PerfCI
  participant PerfReport
  DuplicateHeavyRenderer->>PerfCI: Measure cached SSR CPU, wall time, and allocations
  PerfCI->>PerfReport: Emit benchmark metrics
  PerfReport->>PerfReport: Classify deltas with confidence intervals
Loading

Possibly related PRs

  • unjs/unhead#940: Modifies the same performance-reporting, benchmark, and tag-deduplication areas.
  • unjs/unhead#859: Modifies allocation significance gating and related tests.
  • unjs/unhead#794: Modifies tag normalization, deduplication, cloning, and sanitization logic.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the performance improvement to duplicate tag resolution, which is the main change.
Description check ✅ Passed The description includes the linked issue, change types, problem, solution, safety behavior, and benchmark coverage.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/ssr-dedupe-hot-path

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@harlan-zw
harlan-zw changed the base branch from perf/ssr-duplicate-benchmark to main August 12, 2026 03:20
@harlan-zw harlan-zw closed this Aug 12, 2026
@harlan-zw harlan-zw reopened this Aug 12, 2026
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Size

⚠️ 12 bundles grew · net +0.6 kB gz

Bundle Gzipped Δ
Client (Minimal) 5.3 kB → 5.4 kB 🔴 +43 B (+0.8%)
Client (Full) 9.6 kB → 9.6 kB 🔴 +50 B (+0.5%)
Client (Self-Contained) 5.7 kB → 5.7 kB 🔴 +52 B (+0.9%)
Server (Minimal) 4.9 kB → 4.9 kB 🔴 +52 B (+1.0%)
Server (Self-Contained) 5.2 kB → 5.3 kB 🔴 +58 B (+1.1%)
Vue Client (Minimal) 5.8 kB → 5.9 kB 🔴 +56 B (+0.9%)
Vue Client (Full) 10.6 kB → 10.7 kB 🔴 +60 B (+0.6%)
Vue Server (Minimal) 5.3 kB → 5.4 kB 🔴 +56 B (+1.0%)
React Client (Minimal) 5.8 kB → 5.9 kB 🔴 +54 B (+0.9%)
React Client (Full) 10.7 kB → 10.8 kB 🔴 +49 B (+0.4%)
React Server (Minimal) 5.2 kB → 5.2 kB 🔴 +58 B (+1.1%)
Schema.org (Minimal) 11.7 kB → 11.8 kB 🔴 +59 B (+0.5%)
All bundles (14)
Bundle Gzipped Brotli Raw
Core
Client (Minimal) 5.4 kB 4.9 kB 13.6 kB 🔴
Client (Full) 9.6 kB 8.7 kB 25.1 kB 🔴
Client (Self-Contained) 5.7 kB 5.2 kB 14.4 kB 🔴
Server (Minimal) 4.9 kB 4.5 kB 12.4 kB 🔴
Server (Self-Contained) 5.3 kB 4.7 kB 13.1 kB 🔴
Vue
Vue Client (Minimal) 5.9 kB 5.4 kB 14.6 kB 🔴
Vue Client (Full) 10.7 kB 9.7 kB 27.9 kB 🔴
Vue Server (Minimal) 5.4 kB 4.9 kB 13.4 kB 🔴
React
React Client (Minimal) 5.9 kB 5.3 kB 14.7 kB 🔴
React Client (Full) 10.8 kB 9.8 kB 28.3 kB 🔴
React Server (Minimal) 5.2 kB 4.7 kB 13.1 kB 🔴
Schema.org
Schema.org (Minimal) 11.8 kB 10.5 kB 34.4 kB 🔴
Schema.org Imports 0.1 kB 0.1 kB 0.1 kB
Schema.org Vue Meta 0.5 kB 0.4 kB 1 kB

📦 Runtime Dependencies

No runtime dependency changes

All packages (10)
Package External deps Install size Largest dependency Skipped optional
@unhead/angular 10 773.6 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/bundler 10 786.9 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/cli 18 4.5 MB @oxc-parser/binding-linux-arm64-gnu 2 MB 18
@unhead/eslint-plugin 9 683.2 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/react 11 810.7 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/schema-org 9 683.2 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/solid-js 11 810.7 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/svelte 11 810.7 kB @jridgewell/trace-mapping 146.7 kB 0
@unhead/vue 11 810.7 kB @jridgewell/trace-mapping 146.7 kB 0
unhead 9 683.2 kB @jridgewell/trace-mapping 146.7 kB 0
Skipped optional dependencies (18)
  • @unhead/cli: oxc-parser -> @oxc-parser/binding-android-arm-eabi, oxc-parser -> @oxc-parser/binding-android-arm64, oxc-parser -> @oxc-parser/binding-darwin-arm64, oxc-parser -> @oxc-parser/binding-darwin-x64, oxc-parser -> @oxc-parser/binding-freebsd-x64, oxc-parser -> @oxc-parser/binding-linux-arm-gnueabihf, oxc-parser -> @oxc-parser/binding-linux-arm-musleabihf, oxc-parser -> @oxc-parser/binding-linux-arm64-musl, oxc-parser -> @oxc-parser/binding-linux-ppc64-gnu, oxc-parser -> @oxc-parser/binding-linux-riscv64-gnu, oxc-parser -> @oxc-parser/binding-linux-riscv64-musl, oxc-parser -> @oxc-parser/binding-linux-s390x-gnu, oxc-parser -> @oxc-parser/binding-linux-x64-gnu, oxc-parser -> @oxc-parser/binding-linux-x64-musl, oxc-parser -> @oxc-parser/binding-openharmony-arm64, oxc-parser -> @oxc-parser/binding-win32-arm64-msvc, oxc-parser -> @oxc-parser/binding-win32-ia32-msvc, oxc-parser -> @oxc-parser/binding-win32-x64-msvc

Production dependencies only. Peer dependencies and Unhead workspace packages are excluded. Skipped optional dependencies are unavailable on the CI platform.


⚡ Performance (directional)

🟢 2 faster

Benchmark base → PR Δ
Duplicate-heavy cached render (CPU) 0.552 ms → 0.128 ms 🟢 -76.8%
Duplicate-heavy cached allocated / render 1130.7 KiB → 126.7 KiB 🟢 -1004 KiB (-88.8%)
All benchmarks (28)
Benchmark PR Δ RME
SSR render (CPU) 0.362 ms ~ +0.2% ±9.7%
SSR render (wall) 0.262 ms ~ -0.7% ±4.8%
SSR allocated / render 243 KiB ~ +3.2% ±4.9%
Duplicate-heavy cached render (CPU) 0.128 ms 🟢 -76.8% ±7.6%
Duplicate-heavy cached render (wall) 0.115 ms ℹ️ -77.5% ±2.2%
Duplicate-heavy cached allocated / render 126.7 KiB 🟢 -1004 KiB (-88.8%) ±0.1%
Schema.org cached render (CPU) 0.349 ms ~ -1.5% ±5.5%
Schema.org cached render (wall) 0.253 ms ~ -0.9% ±2.2%
Schema.org cached allocated / render 131.2 KiB ~ +0.7% ±1.0%
Streaming wrapStream drain (CPU) 0.240 ms ~ -3.5% ±5.2%
Streaming wrapStream drain (wall) 0.149 ms ~ -2.0% ±5.0%
Streaming allocated / drain 149.5 KiB ~ +0.0% ±0.3%
Streaming suspense chunk (CPU) 0.014 ms ~ +8.2% ±11.9%
Streaming allocated / suspense chunk 5.1 KiB ~ -0.2% ±0.5%
CSR DOM mutations / nav 38 ~ -0.0%
CSR re-render (CPU) 0.924 ms ~ +0.2% ±4.0%
CSR re-render (wall) 0.487 ms ~ +1.2% ±2.1%
Bundler: transformInclude mixed ids 0.395 ms ~ +3.7% ±1.8%
Bundler: useSeoMetaTransform static calls 3.488 ms ~ -2.2% ±4.1%
Bundler: minifyTransform inline script/style 0.514 ms ~ -1.2% ±3.5%
Bundler: treeshakeServerComposables many calls 2.632 ms ~ -2.0% ±7.1%
Bundler: treeshakeServerComposables skip unrelated code 0.002 ms ~ +0.5% ±1.0%
Bundler: ssrStaticReplace many head.ssr reads 1.658 ms ~ -2.3% ±6.2%
Bundler: ssrStaticReplace skip unrelated code 0.002 ms ~ -0.2% ±1.2%
Bundler: createHeadTransform many createHead calls 0.584 ms ~ -0.3% ±4.9%
Bundler: react streaming skip JSX without head calls 0.003 ms ~ +0.4% ±1.3%
Bundler: react streaming transform JSX with head calls 1.596 ms ~ -0.7% ±6.5%
Bundler: solid streaming skip JSX without head calls 0.003 ms ~ -0.1% ±1.2%

Baseline: main @ 29d105a · 2026-08-12 · gzipped is the headline size metric · perf is directional (shared-runner, gated)

@harlan-zw
harlan-zw changed the base branch from main to perf/ssr-duplicate-benchmark August 12, 2026 03:25
@harlan-zw
harlan-zw changed the base branch from perf/ssr-duplicate-benchmark to main August 12, 2026 05:36
@harlan-zw harlan-zw closed this Aug 12, 2026
@harlan-zw harlan-zw reopened this Aug 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/unhead/src/utils/resolve.ts`:
- Line 80: Update the duplicate-handling logic in the resolve flow around
ownedMergeProps so both non-merge branches remove the current key from
ownedMergeProps after replacing the mapped tag or appending a new final array
item. Add a regression test that renders, disposes, and renders again to verify
later merges do not retain attributes from the disposed entry.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f9ebe0c7-f7d9-4797-88c0-1b86820ffdba

📥 Commits

Reviewing files that changed from the base of the PR and between 29d105a and e6a4f46.

📒 Files selected for processing (7)
  • .github/workflows/bundle-size.yml
  • bench/bundle/perf-report.test.ts
  • bench/bundle/perf-report.ts
  • bench/perf-ci.mjs
  • packages/unhead/src/utils/resolve.ts
  • packages/unhead/test/unit/server/deduping.test.ts
  • packages/unhead/test/unit/server/prototype-pollution.test.ts

Comment thread packages/unhead/src/utils/resolve.ts Outdated
@harlan-zw
harlan-zw changed the base branch from main to perf/ssr-duplicate-benchmark August 12, 2026 05:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant