Skip to content

perf(unhead): skip defensive tag clones when no mutating hooks registered#776

Merged
harlan-zw merged 4 commits into
mainfrom
perf/resolve-pipeline
Jun 12, 2026
Merged

perf(unhead): skip defensive tag clones when no mutating hooks registered#776
harlan-zw merged 4 commits into
mainfrom
perf/resolve-pipeline

Conversation

@harlan-zw

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

Copy link
Copy Markdown
Collaborator

🔗 Linked issue

n/a

❓ Type of change

  • 📖 Documentation
  • 🐞 Bug fix
  • 👌 Enhancement
  • ✨ New feature
  • 🧹 Chore
  • ⚠️ Breaking change

📚 Description

resolveTags cloned every tag plus its props on every render to protect cached entry tags from hook mutation. A CPU profile of the SSR e2e bench showed the resolve pipeline at ~56% of self time while actual HTML rendering took ~17%, with the defensive clone and its GC churn as the biggest avoidable cost.

Changes:

  • Skip the per-render tag clone unless a registered hook can actually mutate resolved tags (tags:*, ssr:render(ed), dom:rendered, matched by name). The server unhead:payload hook only appends a fresh tag, so it is marked _nonMutating and no longer forces the clone on every SSR render.
  • sanitizeTags is copy-on-write: script tags are copied before escaping instead of relying on the render clone.
  • normalizeEntryToTags skips building the resolver closure when no propResolvers are registered, which speeds up core (non-framework) usage without touching the walk semantics.
  • Smaller cleanups: tagToString caches close-tag regexes instead of allocating one per call, capo script weights inlined with redundant async re-checks dropped, escapeHtml switch replaced with a lookup map, client createHead reuses TagPriorityAliases, the double-deprecated renderSSRHead wrapper in unhead/server collapsed to a re-export, and the invalid optionalPlugins field removed from package.json.
  • Regression test: propsToString skips enumerable inherited props (the Object.hasOwn serialization guard now has coverage), and hook mutations of class/style containers no longer leak into the entry cache across renders.

📊 Benchmarks

vitest bench (bench/*), main vs this branch:

bench main PR change
useSeoMeta x50 SSR (vite transformed) 299 ops/s 366 ops/s +22%
useSeoMeta x50 SSR (runtime) 133 ops/s 136 ops/s +3%
no-schema simple 155.4k ops/s 182.0k ops/s +17%
no-schema e2e 12,762 ops/s 12,940 ops/s +1.4%
harlanzw.com e2e 4,440 ops/s 4,542 ops/s +2.3%
templateParams 367k ops/s 372k ops/s unchanged

Standalone core (no plugins, 20k iterations): renderSSRHead 68.5k -> ~80k ops/s (+17%), transformHtmlTemplate 46.4k -> ~57k ops/s (+22%). GC pauses over 200k simulated requests dropped ~11%.

Pages using plugins that register tag-mutating hooks (templateParams, inferSeoMeta) keep the clone and see smaller gains; plain useHead/useSeoMeta setups get the full win.

📦 Bundle size

bench/bundle vs last.json:

build min gz
server -186 +14
vue server -186 +13
client +217 +91
vue client +217 +94

The first iteration of this PR also fused prop resolution into a single normalization pass, worth another ~5% core / +27pp on the transformed useSeoMeta bench, but it cost +90 gz on the client entry so it was dropped. Easy to revisit if the trade reads differently.

Note: pnpm typecheck fails locally on main (unused @ts-expect-error in packages/schema-org/src/svelte.ts:14), unrelated to this diff; CI typecheck passes.

Summary by CodeRabbit

  • Bug Fixes

    • Reduced unnecessary cloning during server renders to improve efficiency and prevent cross-request mutations.
    • Hardened tag sanitization to avoid leaking mutated tag data.
  • Refactor

    • Standardized tag weighting for more consistent tag ordering.
    • Simplified server export surface by removing a deprecated wrapper.
    • Improved HTML escaping and attribute encoding.
  • Chores

    • Cleaned up package metadata.
  • Tests

    • Added tests for attribute serialization and to ensure hook mutations do not persist across renders.

resolveTags cloned every tag and its props on each render to protect
cached entry tags from hook mutation. Only registered tags:*/ssr:render(ed)/
dom:rendered hooks can mutate resolved tags, so the clone now only happens
when one is present, and the server payload plugin hook is marked
non-mutating since it only appends. sanitizeTags copies script tags before
escaping instead of relying on the render clone.

normalizeEntryToTags previously deep-cloned the whole input via walkResolver
before normalizeProps walked it again. Prop resolution now happens inline
during normalizeProps in a single traversal with identical resolver call
semantics.

Also: hook calls skip context allocation when no listeners are registered,
tagToString caches close-tag regexes, capo weights inlined with redundant
async re-checks dropped, escapeHtml switch replaced with a lookup map,
client createHead reuses TagPriorityAliases, double-deprecated renderSSRHead
wrapper collapsed, and the bogus optionalPlugins package.json field removed.
@coderabbitai

coderabbitai Bot commented Jun 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

Run ID: 037fce7a-a791-40b4-aed3-344b51be46a7

📥 Commits

Reviewing files that changed from the base of the PR and between 078395d and 9a06597.

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

📝 Walkthrough

Walkthrough

Refactors client/server tag-weighting, tag resolution, normalization, and HTML rendering utilities; introduces DEFAULT_TAG_WEIGHT and conditional cloning for mutating hooks; adds tests; removes package metadata and re-exports server render helpers.

Changes

Tag Resolution and Rendering Refactor

Layer / File(s) Summary
Package cleanup
packages/unhead/package.json
Removes the optionalPlugins metadata block from typesVersions.
Client-side tag priority resolution
packages/unhead/src/client/createHead.ts
tagWeight now uses shared TagPriorityAliases for alias fallback weights.
Server tag weight sorting refactor
packages/unhead/src/server/sort.ts
Introduces LINK_WEIGHTS and rewrites capoTagWeight branching for meta, link, script, and style weight selection.
Server HTML rendering utilities optimization
packages/unhead/src/server/util/propsToString.ts, packages/unhead/src/server/util/tagToString.ts, packages/unhead/test/unit/server/util.test.ts
encodeAttribute uses local coercion and conditional escaping; escapeHtml uses a lookup map; closing-tag rewrites use cached regexes; adds test ensuring inherited enumerable props are ignored.
Tag deduplication constant extraction
packages/unhead/src/utils/dedupe.ts
Adds META_KEY_ATTRS constant and iterates over it in dedupeKey.
Normalization resolver composition
packages/unhead/src/utils/normalize.ts
Constructs and applies resolve only when propResolvers exist and passes it to walkResolver.
Resolution defaults and mutability-aware cloning
packages/unhead/src/utils/resolve.ts, packages/unhead/test/unit/hooks/mutating-hooks.test.ts
Adds DEFAULT_TAG_WEIGHT, refactors sanitizeTags to copy-on-write script escaping and recompute dedupe keys, conditionally clones resolved tags when hooks may mutate, and adds a test ensuring hook mutations don't leak between SSR renders.
Server payload hook and export cleanup
packages/unhead/src/server/createHead.ts, packages/unhead/src/server/index.ts
Defines payloadHook for tags:resolve marked _nonMutating and re-exports renderSSRHead directly from ./renderSSRHead, removing the deprecated wrapper.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • unjs/unhead#715: Related fixes to avoid mutating cached tags during resolution (similar cache/mutation concerns).
  • unjs/unhead#734: Overlaps in server-side script handling and resolve/escaping logic for script content.
  • unjs/unhead#712: Related to hook handling and how mutating hooks interact with resolution lifecycle.

Poem

🐰 I tidy weights and guard the render,
I clone and clean so caches don't blur,
I map the links and escape each quote,
I tuck a payloadhook in my coat,
Hop—refactor, test, and purr.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.36% 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
Title check ✅ Passed The title 'perf(unhead): skip defensive tag clones when no mutating hooks registered' clearly and specifically summarizes the main performance optimization in the changeset using conventional commit format.
Description check ✅ Passed The description is comprehensive and well-structured, covering linked issues, type of change selection, and detailed explanation of the changes with benchmarks and bundle size analysis. All required template sections are properly filled.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/resolve-pipeline

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 and usage tips.

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Bundle Size Gzipped
Client (Minimal) 10.8 kB → 11.1 kB 🔴 +0.3 kB 4.4 kB → 4.5 kB 🔴 +0.2 kB
Server (Minimal) 10.7 kB → 10.7 kB 🟢 0.1 kB 4.3 kB → 4.4 kB 🔴 +0.1 kB
Vue Client (Minimal) 11.8 kB → 12.1 kB 🔴 +0.3 kB 4.9 kB → 5 kB 🔴 +0.2 kB
Vue Server (Minimal) 11.7 kB → 11.6 kB 🟢 0.1 kB 4.8 kB → 4.8 kB 🔴 +0.1 kB

@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: 2

🤖 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/normalize.ts`:
- Around line 55-60: The for...in loop in normalize.ts iterates inherited
enumerable properties; before reading input[prop] inside the loop that uses
isUnsafeKey and walkResolver, add an ownership guard using Object.hasOwn(input,
prop) (i.e. skip if !Object.hasOwn(input, prop)); do the same fix for the other
for...in loop referenced (around the block at lines 131-143) so inherited keys
are never promoted into tag.props; keep existing isUnsafeKey and walkResolver
logic but only run them when the property is an own property.

In `@packages/unhead/src/utils/resolve.ts`:
- Around line 145-148: The needsClone branch currently shallow-copies tag.props
but leaves container props like class (Set) and style (Map) shared; update the
cloning logic in the block that computes ctx.tags (the code using
TAG_MUTATING_HOOKS, hooks, ctx.tags and entries) to deep-clone or recreate
tag.props.class and tag.props.style when they exist (e.g., new
Set(tag.props.class) and new Map(tag.props.style)) so mutating hooks cannot
mutate cached e._tags; ensure you still copy other props shallowly as before and
only perform the extra cloning when needsClone is true.
🪄 Autofix (Beta)

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

Run ID: ef041128-f324-4867-a269-a7a8635c5f59

📥 Commits

Reviewing files that changed from the base of the PR and between 800273a and 12479c5.

📒 Files selected for processing (11)
  • packages/unhead/package.json
  • packages/unhead/src/client/createHead.ts
  • packages/unhead/src/server/createHead.ts
  • packages/unhead/src/server/index.ts
  • packages/unhead/src/server/sort.ts
  • packages/unhead/src/server/util/propsToString.ts
  • packages/unhead/src/server/util/tagToString.ts
  • packages/unhead/src/utils/dedupe.ts
  • packages/unhead/src/utils/normalize.ts
  • packages/unhead/src/utils/resolve.ts
  • packages/unhead/test/unit/server/util.test.ts
💤 Files with no reviewable changes (1)
  • packages/unhead/package.json

Comment thread packages/unhead/src/utils/normalize.ts Outdated
Comment thread packages/unhead/src/utils/resolve.ts Outdated
Gzip impact matters more to consumers than the last few percent of SSR
throughput. Revert normalizeEntryToTags to the walkResolver pass (keeping
a skip of the resolver closure when no propResolvers are registered) and
drop the per-prop resolution from normalizeProps. Replace the spelled-out
tag-mutating hook list with a regex test over registered hook names.

Against bench/bundle baselines the server entry is now 186 bytes smaller
than main (+14 gz) and the client entry costs +217 min / +91 gz, down from
+642 / +250 at the first commit. The clone-skip keeps the bulk of the perf:
+22% transformed useSeoMeta x50 SSR and +17% core renderSSRHead vs main,
giving back the fused-normalization gains on plugin-heavy pages.
@harlan-zw harlan-zw changed the title perf(unhead): skip defensive tag clones and fuse normalization passes perf(unhead): skip defensive tag clones when no mutating hooks registered Jun 12, 2026
The defensive clone copied props one level deep, so a tags:* hook calling
tag.props.class.add() or tag.props.style.set() mutated the Set/Map shared
with the cached entry tags and leaked into later renders (pre-existing on
main, surfaced by CodeRabbit on this diff). Copy the two containers in the
clone path and add a regression test that fails against the shallow copy.

@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/normalize.ts`:
- Around line 102-110: The code pre-applies propResolvers by calling
resolve(undefined, input) before calling walkResolver, but walkResolver already
calls resolve(undefined, val) for the root, causing double invocation; remove
the pre-run line so propResolvers are only applied via walkResolver (i.e.,
delete or comment out the input = resolve(undefined, input) call) and keep the
resolve function and input = walkResolver(input, resolve) as-is to ensure root
is resolved exactly once.
🪄 Autofix (Beta)

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

Run ID: 8952fd58-f442-48cf-b811-fb00df7b2f6c

📥 Commits

Reviewing files that changed from the base of the PR and between 12479c5 and ee655bf.

📒 Files selected for processing (2)
  • packages/unhead/src/utils/normalize.ts
  • packages/unhead/src/utils/resolve.ts

Comment thread packages/unhead/src/utils/normalize.ts
props.class/style are typed as string but are Set/Map at runtime after
normalization; widen the clone's props record and route the test casts
through unknown.
@harlan-zw harlan-zw merged commit 032f2c1 into main Jun 12, 2026
8 checks passed
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