Skip to content

perf(unhead): optimize SSR rendering hot paths - #940

Closed
harlan-zw wants to merge 12 commits into
perf/ssr-duplicate-benchmarkfrom
perf/ssr-render-hot-paths
Closed

perf(unhead): optimize SSR rendering hot paths#940
harlan-zw wants to merge 12 commits into
perf/ssr-duplicate-benchmarkfrom
perf/ssr-render-hot-paths

Conversation

@harlan-zw

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

Copy link
Copy Markdown
Collaborator

❓ 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

This removes work from each SSR pipeline stage:

  • Input resolution and property normalization now share one traversal. Resolver chains compile once and refresh when plugins change them.
  • Built-in hooks carry private provenance, so normalized attributes use the faster serializer. Custom hooks and resolvedTags keep full validation.
  • Duplicate merges use request-owned accumulators. Repeated meta values append without copying the full array.
  • Stable Schema.org graphs reuse serialized JSON. Mutations, Vue refs, callbacks, custom resolvers, and custom tag hooks disable or invalidate reuse.
  • Schema cache state is isolated per head. Cache validation inspects graph dependencies instead of unrelated static entries.
  • Template token processing avoids repeated URI errors. The default server event resolver skips its empty chain.
  • CI includes a duplicate-heavy renderer and uses the combined 95% confidence interval once. It also reports noisy deltas and fails on missing SSR metrics.

Local shipped-output comparisons against current main show:

  • Duplicate-heavy cached SSR: 66% less CPU and 88% less allocation.
  • Cached Schema.org SSR: 54% less CPU and 37% less allocation.
  • Fresh Vue SSR e2e: 6% to 24% faster across repeated samples.

All 1,980 tests pass, with 8 skipped. Lint, typecheck, full build, and local performance reporting pass.

Summary by CodeRabbit

  • Bug Fixes

    • Improved server-rendered metadata handling for duplicate tags and merged attributes.
    • Prevented cached metadata and attributes from changing unexpectedly across renders.
    • Blocked invalid, unsafe, inherited, and unintended event-handler attributes.
    • Ensured render-time changes are applied immediately.
    • Preserved encoded template parameters while continuing to resolve regular tokens.
    • Prevented server-side event handlers from executing during rendering.
  • Performance

    • Optimized trusted server-side rendering and schema.org output caching.
    • Added benchmarks for duplicate-tag and repeated-attribute scaling.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Unhead now shares hook-aware SSR orchestration, supports validated and trusted serialization, improves resolver and deduplication handling, and serializes server event handlers. Schema.org adds snapshot-based SSR graph caching. Performance tooling now propagates failures and reports confidence intervals.

Changes

Unhead rendering and resolution

Layer / File(s) Summary
Resolver and deduplication state
packages/unhead/src/utils/..., packages/unhead/src/client/createHead.ts, packages/unhead/src/server/createHead.ts, packages/unhead/src/types/head.ts, packages/unhead/src/plugins/*
Property resolvers support cached chains and server event handlers. Normalization filters unsafe keys and marks static hooks. Deduplication preserves merged attributes and array metadata without mutating cached entries.
Trusted SSR serialization
packages/unhead/src/server/util/*.ts
SSR serialization provides validated and trusted paths for properties, tags, and attributes. Title escaping and closing-tag protection remain in the shared renderer.
Hook-aware SSR orchestration
packages/unhead/src/server/renderSSRHead.ts, packages/unhead/test/unit/hooks/mutating-hooks.test.ts, packages/unhead/test/unit/server/*.test.ts, bench/resolve-tags.bench.ts
Server renderers delegate to renderServerHead, apply lifecycle mutations, and select serialization based on unsafe hooks. Tests cover invalid attributes, prototype pollution, event handlers, and repeated renders.
Template parameter processing
packages/unhead/src/utils/templateParams.ts, packages/unhead/test/unit/templateParams.test.ts
Template processing decodes only strings with encoded sequences and preserves malformed input.

Schema.org SSR graph cache

Layer / File(s) Summary
Schema graph cache
packages/schema-org/src/core/define.ts, packages/schema-org/src/runtime.ts, packages/schema-org/src/plugin.ts, packages/schema-org/test/ssr/cache.test.ts
Schema.org tracks built-in nodes and caches graph HTML when entries, options, metadata, resolvers, and hooks remain cacheable. Tests cover invalidation and dynamic inputs.

CI performance reporting

Layer / File(s) Summary
Performance command failures
.github/workflows/bundle-size.yml
Base and pull-request performance commands now fail the workflow when the harness exits unsuccessfully.
Performance measurement and reporting
bench/perf-ci.mjs, bench/bundle/perf-report.ts, bench/bundle/perf-report.test.ts
Duplicate-heavy SSR measurements were added. Allocation gates use combined confidence intervals. Informational metrics show percentage deltas.

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

Sequence Diagram(s)

sequenceDiagram
  participant createServerRenderer
  participant renderServerHead
  participant resolveTags
  participant ssrRenderTags
  participant ssrRenderTagsTrusted
  createServerRenderer->>renderServerHead: render head
  renderServerHead->>renderServerHead: run lifecycle hooks
  renderServerHead->>resolveTags: resolve and deduplicate tags
  renderServerHead->>ssrRenderTags: serialize customized tags
  renderServerHead->>ssrRenderTagsTrusted: serialize trusted tags
Loading

Possibly related PRs

  • unjs/unhead#776: Both changes modify hook-aware SSR rendering, deduplication, and normalization.
  • unjs/unhead#814: Both changes modify attribute validation and unsafe-key handling.
  • unjs/unhead#871: Both changes modify Schema.org SSR graph caching and reuse.

Suggested labels: ssr, enhancement

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% 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 and concisely summarizes the main change: optimizing SSR rendering hot paths.
Description check ✅ Passed The description identifies the change types, explains the main optimizations, reports validation results, and provides performance measurements.
✨ 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-render-hot-paths

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.

@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`:
- Around line 99-112: In packages/unhead/src/utils/resolve.ts#L99-L112, update
the property merge loop to process only own source keys and reject __proto__,
constructor, and prototype before assigning to props; preserve the existing
style and class merging behavior. In
packages/unhead/src/server/util/propsToString.ts#L15-L17, always skip keys that
are not own properties of props, while trusted mode may only bypass empty-name
and attribute-name validation. Add a regression covering parsed JSON with a
__proto__ value merged into htmlAttrs.
🪄 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: 3d91e5b9-c047-400a-a7fb-6b675b959874

📥 Commits

Reviewing files that changed from the base of the PR and between 1dfd68f and d65aa8a.

📒 Files selected for processing (8)
  • bench/resolve-tags.bench.ts
  • packages/unhead/src/server/renderSSRHead.ts
  • packages/unhead/src/server/util/propsToString.ts
  • packages/unhead/src/server/util/ssrRenderTags.ts
  • packages/unhead/src/server/util/tagToString.ts
  • packages/unhead/src/utils/resolve.ts
  • packages/unhead/test/unit/hooks/mutating-hooks.test.ts
  • packages/unhead/test/unit/server/deduping.test.ts

Comment thread packages/unhead/src/utils/resolve.ts
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Size

⚠️ 12 bundles grew · net +7.1 kB gz

Bundle Gzipped Δ
Client (Minimal) 5.3 kB → 5.8 kB 🔴 +0.4 kB (+8.0%)
Client (Full) 9.6 kB → 10.1 kB 🔴 +0.5 kB (+5.2%)
Client (Self-Contained) 5.7 kB → 6.1 kB 🔴 +0.4 kB (+7.2%)
Server (Minimal) 4.9 kB → 5.5 kB 🔴 +0.6 kB (+12.3%)
Server (Self-Contained) 5.2 kB → 5.9 kB 🔴 +0.7 kB (+12.5%)
Vue Client (Minimal) 5.8 kB → 6.3 kB 🔴 +0.4 kB (+7.3%)
Vue Client (Full) 10.6 kB → 11.1 kB 🔴 +0.5 kB (+4.7%)
Vue Server (Minimal) 5.3 kB → 6 kB 🔴 +0.6 kB (+11.4%)
React Client (Minimal) 5.8 kB → 6.2 kB 🔴 +0.4 kB (+7.3%)
React Client (Full) 10.7 kB → 11.2 kB 🔴 +0.5 kB (+4.5%)
React Server (Minimal) 5.2 kB → 5.7 kB 🔴 +0.6 kB (+11.4%)
Schema.org (Minimal) 11.7 kB → 13.2 kB 🔴 +1.5 kB (+13.0%)
All bundles (14)
Bundle Gzipped Brotli Raw
Core
Client (Minimal) 5.8 kB 5.2 kB 14.6 kB 🔴
Client (Full) 10.1 kB 9.1 kB 26.3 kB 🔴
Client (Self-Contained) 6.1 kB 5.5 kB 15.4 kB 🔴
Server (Minimal) 5.5 kB 5 kB 14.1 kB 🔴
Server (Self-Contained) 5.9 kB 5.3 kB 14.9 kB 🔴
Vue
Vue Client (Minimal) 6.3 kB 5.7 kB 15.7 kB 🔴
Vue Client (Full) 11.1 kB 10 kB 29.1 kB 🔴
Vue Server (Minimal) 6 kB 5.4 kB 15.2 kB 🔴
React
React Client (Minimal) 6.2 kB 5.7 kB 15.7 kB 🔴
React Client (Full) 11.2 kB 10.2 kB 29.5 kB 🔴
React Server (Minimal) 5.7 kB 5.2 kB 14.8 kB 🔴
Schema.org
Schema.org (Minimal) 13.2 kB 11.9 kB 38.9 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)

🟢 4 faster

Benchmark base → PR Δ
Duplicate-heavy cached render (CPU) 0.554 ms → 0.173 ms 🟢 -68.8%
Duplicate-heavy cached allocated / render 1130.7 KiB → 139.2 KiB 🟢 -991.5 KiB (-87.7%)
Schema.org cached render (CPU) 0.358 ms → 0.151 ms 🟢 -57.9%
Schema.org cached allocated / render 128.7 KiB → 83.5 KiB 🟢 -45.2 KiB (-35.1%)
All benchmarks (28)
Benchmark PR Δ RME
SSR render (CPU) 0.330 ms ~ -8.5% ±10.5%
SSR render (wall) 0.236 ms ℹ️ -10.3% ±5.3%
SSR allocated / render 229.7 KiB ~ -2.7% ±4.2%
Duplicate-heavy cached render (CPU) 0.173 ms 🟢 -68.8% ±3.7%
Duplicate-heavy cached render (wall) 0.125 ms ℹ️ -75.8% ±1.0%
Duplicate-heavy cached allocated / render 139.2 KiB 🟢 -991.5 KiB (-87.7%) ±0.1%
Schema.org cached render (CPU) 0.151 ms 🟢 -57.9% ±12.3%
Schema.org cached render (wall) 0.099 ms ℹ️ -61.5% ±4.0%
Schema.org cached allocated / render 83.5 KiB 🟢 -45.2 KiB (-35.1%) ±4.0%
Streaming wrapStream drain (CPU) 0.241 ms ~ -1.4% ±4.9%
Streaming wrapStream drain (wall) 0.149 ms ~ -2.9% ±4.8%
Streaming allocated / drain 149.1 KiB ~ -0.5% ±0.3%
Streaming suspense chunk (CPU) 0.012 ms ~ -13.1% ±6.7%
Streaming allocated / suspense chunk 4.9 KiB ~ -4.7% ±0.5%
CSR DOM mutations / nav 38 ~ -0.0%
CSR re-render (CPU) 0.917 ms ~ -3.2% ±5.2%
CSR re-render (wall) 0.487 ms ~ -1.4% ±2.4%
Bundler: transformInclude mixed ids 0.389 ms ~ -1.1% ±0.7%
Bundler: useSeoMetaTransform static calls 3.571 ms ~ -1.2% ±4.2%
Bundler: minifyTransform inline script/style 0.516 ms ~ -1.4% ±3.1%
Bundler: treeshakeServerComposables many calls 2.603 ms ~ -0.7% ±6.2%
Bundler: treeshakeServerComposables skip unrelated code 0.002 ms ~ -0.5% ±1.0%
Bundler: ssrStaticReplace many head.ssr reads 1.649 ms ~ -1.6% ±6.5%
Bundler: ssrStaticReplace skip unrelated code 0.002 ms ~ -0.0% ±0.5%
Bundler: createHeadTransform many createHead calls 0.595 ms ~ -1.6% ±4.9%
Bundler: react streaming skip JSX without head calls 0.003 ms ~ +1.5% ±1.3%
Bundler: react streaming transform JSX with head calls 1.601 ms ~ -0.5% ±6.5%
Bundler: solid streaming skip JSX without head calls 0.003 ms ~ +2.3% ±1.2%

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

@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.

🧹 Nitpick comments (2)
packages/unhead/test/unit/server/prototype-pollution.test.ts (2)

105-111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Select the test entry by its data, not by hook call count.

The hook at Line [110] depends on the second entries:normalize call. If the lifecycle adds another normalization pass, the payload can target the wrong entry or no entry. Select the htmlAttrs tag whose class is second.

Proposed test fix
-    let normalizeCount = 0
     let inheritedOnload: unknown
@@
-          if (++normalizeCount === 2)
-            tags[0].props = JSON.parse('{"lang":"en","__proto__":{"onload":"alert(1)"}}')
+          const htmlAttrs = tags.find(tag => tag.tag === 'htmlAttrs')
+          if (htmlAttrs?.props?.class === 'second')
+            htmlAttrs.props = JSON.parse('{"lang":"en","__proto__":{"onload":"alert(1)"}}')
🤖 Prompt for 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.

In `@packages/unhead/test/unit/server/prototype-pollution.test.ts` around lines
105 - 111, Update the entries:normalize hook to locate the htmlAttrs tag whose
class is "second" and assign the prototype-pollution payload to that tag,
instead of relying on normalizeCount reaching 2. Remove the call-count
dependency while preserving the existing payload and test behavior.

121-124: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that safe attributes survive filtering.

The payload at Line [111] includes valid lang data, but the test only checks that onload is absent. An implementation that drops the complete props object would pass. Assert that the rendered output keeps lang="en".

Proposed test assertion
     expect(inheritedOnload).toBeUndefined()
+    expect(result.htmlAttrs).toContain('lang="en"')
     expect(result.htmlAttrs).not.toContain('onload')
🤖 Prompt for 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.

In `@packages/unhead/test/unit/server/prototype-pollution.test.ts` around lines
121 - 124, Update the assertions in the prototype-pollution test around
renderSSRHead to verify that result.htmlAttrs preserves the valid lang="en"
attribute, while retaining the existing checks that inherited onload data is
absent.
🤖 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.

Nitpick comments:
In `@packages/unhead/test/unit/server/prototype-pollution.test.ts`:
- Around line 105-111: Update the entries:normalize hook to locate the htmlAttrs
tag whose class is "second" and assign the prototype-pollution payload to that
tag, instead of relying on normalizeCount reaching 2. Remove the call-count
dependency while preserving the existing payload and test behavior.
- Around line 121-124: Update the assertions in the prototype-pollution test
around renderSSRHead to verify that result.htmlAttrs preserves the valid
lang="en" attribute, while retaining the existing checks that inherited onload
data is absent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f71e190-48e6-4f79-b3f0-5c806f845fdb

📥 Commits

Reviewing files that changed from the base of the PR and between d65aa8a and 8f07ff4.

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

@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

🧹 Nitpick comments (2)
packages/schema-org/test/ssr/cache.test.ts (1)

5-93: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test that asserts a cache hit.

All five tests assert invalidation or non-caching. They also pass when the cache never activates. That leaves the optimization itself untested and hides a silently disabled cache.

Add a test with static entries that renders twice and asserts the graph is resolved only once, for example by counting invocations of a resolver attached to a built-in node, or by spying on resolveGraph.

🤖 Prompt for 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.

In `@packages/schema-org/test/ssr/cache.test.ts` around lines 5 - 93, Add a
dedicated cache-hit test alongside the existing schema.org render cache tests
using static schema entries and a counting resolver or resolveGraph spy. Render
the same head twice, assert the resolver or graph-resolution function runs only
once, and keep the test focused on proving the second render reuses cached
output.
packages/schema-org/src/plugin.ts (1)

25-39: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Expose hook inspection through a supported API. TemplateParamsPlugin and FlatMetaPlugin mark deterministic hooks. Unhead does not register them by default, and dynamic hooks such as PromisesPlugin must remain unmarked. Replace direct head.hooks._hooks access with a public unhead helper.

🤖 Prompt for 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.

In `@packages/schema-org/src/plugin.ts` around lines 25 - 39, Update
hooksAreCacheable to inspect registered hooks through Unhead’s supported public
hook-inspection helper instead of accessing the private head.hooks._hooks
structure. Preserve the existing TagMutationHook filtering and
StaticHook/ownHooks checks, ensuring deterministic plugin hooks remain cacheable
while dynamic hooks remain unmarked.
🤖 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/schema-org/src/plugin.ts`:
- Around line 156-160: Move the per-render state variables cache, entries, and
reuseGraph from the UnheadSchemaOrg factory closure into the defineHeadPlugin
callback that receives each head instance, keeping them initialized identically.
Ensure all entries:resolve and tags:resolve logic uses this callback-local state
so concurrent heads cannot share cached graph or reuse decisions; leave shared
factory-level state such as resolvedMeta unchanged.

In `@packages/unhead/src/utils/normalize.ts`:
- Around line 100-108: Update the value resolution in the normalization flow
around walkResolver so on* function handlers are preserved instead of executed
during normalization, allowing the server event-handler branch to pass the
original function to resolve and create the dataset marker. Add a regression
test covering SSR normalization with an on* handler that throws, asserting the
handler is not invoked and the marker is produced.

---

Nitpick comments:
In `@packages/schema-org/src/plugin.ts`:
- Around line 25-39: Update hooksAreCacheable to inspect registered hooks
through Unhead’s supported public hook-inspection helper instead of accessing
the private head.hooks._hooks structure. Preserve the existing TagMutationHook
filtering and StaticHook/ownHooks checks, ensuring deterministic plugin hooks
remain cacheable while dynamic hooks remain unmarked.

In `@packages/schema-org/test/ssr/cache.test.ts`:
- Around line 5-93: Add a dedicated cache-hit test alongside the existing
schema.org render cache tests using static schema entries and a counting
resolver or resolveGraph spy. Render the same head twice, assert the resolver or
graph-resolution function runs only once, and keep the test focused on proving
the second render reuses cached output.
🪄 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: 601a114b-558d-41e4-8abb-acf1f5961a8b

📥 Commits

Reviewing files that changed from the base of the PR and between 8f07ff4 and cbdedfa.

📒 Files selected for processing (19)
  • .github/workflows/bundle-size.yml
  • packages/schema-org/src/core/define.ts
  • packages/schema-org/src/plugin.ts
  • packages/schema-org/src/runtime.ts
  • packages/schema-org/test/ssr/cache.test.ts
  • packages/unhead/src/client/createHead.ts
  • packages/unhead/src/composables.ts
  • packages/unhead/src/plugins/flatMeta.ts
  • packages/unhead/src/plugins/templateParams.ts
  • packages/unhead/src/server/createHead.ts
  • packages/unhead/src/server/renderSSRHead.ts
  • packages/unhead/src/types/head.ts
  • packages/unhead/src/utils/hooks.ts
  • packages/unhead/src/utils/meta.ts
  • packages/unhead/src/utils/normalize.ts
  • packages/unhead/src/utils/resolve.ts
  • packages/unhead/test/unit/hooks/mutating-hooks.test.ts
  • packages/unhead/test/unit/server/eventHandlers.test.ts
  • packages/unhead/test/unit/server/prototype-pollution.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/unhead/test/unit/server/prototype-pollution.test.ts

Comment thread packages/schema-org/src/plugin.ts Outdated
Comment thread packages/unhead/src/utils/normalize.ts

@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.

🧹 Nitpick comments (2)
packages/unhead/test/unit/templateParams.test.ts (1)

5-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for malformed encoded input.

The implementation has a fallback for malformed URI sequences, but the tests cover only valid %20 input and ordinary template tokens. Add a case that preserves malformed input while still resolving %siteName. This protects the behavior used by packages/schema-org/src/plugin.ts, Line 309.

Suggested regression test
   it('resolves tokens that are not encoded percent sequences', () => {
     expect(processTemplateParams('%s %separator %siteName', {
       pageTitle: 'Page',
       siteName: 'Unhead',
     }, '|')).toBe('Page | Unhead')
   })
+
+  it('preserves malformed encoded input while resolving template tokens', () => {
+    expect(processTemplateParams('https://example.com/%E0%A4%A/%siteName', {
+      siteName: 'Unhead',
+    })).toBe('https://example.com/%E0%A4%A/Unhead')
+  })
🤖 Prompt for 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.

In `@packages/unhead/test/unit/templateParams.test.ts` around lines 5 - 15, Add a
regression test alongside the existing processTemplateParams cases for a
malformed percent-encoded sequence, asserting the malformed input is preserved
while the %siteName token still resolves. Keep the expectation focused on the
fallback behavior used by processTemplateParams.
packages/unhead/src/utils/templateParams.ts (1)

7-7: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Narrow ENCODED_PERCENT_RE before calling decodeURI.

%description, %canonical, and %date match %de, %ca, and %da, so decodeURI throws URIError for valid template tokens. This adds exception overhead on the SSR path. Resolve template tokens before decoding or use a detector that excludes valid token names.

🤖 Prompt for 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.

In `@packages/unhead/src/utils/templateParams.ts` at line 7, Update
ENCODED_PERCENT_RE and its use in the template-parameter decoding flow so valid
tokens such as %description, %canonical, and %date are resolved before decodeURI
or excluded from encoded-percent detection. Ensure decodeURI is only called for
actual percent-encoded byte sequences, avoiding URIError and unnecessary
exception handling for valid template tokens.
🤖 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.

Nitpick comments:
In `@packages/unhead/src/utils/templateParams.ts`:
- Line 7: Update ENCODED_PERCENT_RE and its use in the template-parameter
decoding flow so valid tokens such as %description, %canonical, and %date are
resolved before decodeURI or excluded from encoded-percent detection. Ensure
decodeURI is only called for actual percent-encoded byte sequences, avoiding
URIError and unnecessary exception handling for valid template tokens.

In `@packages/unhead/test/unit/templateParams.test.ts`:
- Around line 5-15: Add a regression test alongside the existing
processTemplateParams cases for a malformed percent-encoded sequence, asserting
the malformed input is preserved while the %siteName token still resolves. Keep
the expectation focused on the fallback behavior used by processTemplateParams.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9e177d49-14be-46ed-93aa-19b05bc5cd78

📥 Commits

Reviewing files that changed from the base of the PR and between cbdedfa and c39643a.

📒 Files selected for processing (11)
  • bench/bundle/perf-report.test.ts
  • bench/bundle/perf-report.ts
  • bench/perf-ci.mjs
  • packages/schema-org/src/plugin.ts
  • packages/schema-org/test/ssr/cache.test.ts
  • packages/unhead/src/composables.ts
  • packages/unhead/src/server/createHead.ts
  • packages/unhead/src/utils/normalize.ts
  • packages/unhead/src/utils/templateParams.ts
  • packages/unhead/test/unit/server/eventHandlers.test.ts
  • packages/unhead/test/unit/templateParams.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/unhead/src/composables.ts
  • packages/unhead/src/server/createHead.ts
  • packages/unhead/src/utils/normalize.ts
  • packages/schema-org/test/ssr/cache.test.ts

@harlan-zw
harlan-zw changed the base branch from main to perf/ssr-duplicate-benchmark August 12, 2026 03:08
@harlan-zw

Copy link
Copy Markdown
Collaborator Author

Superseded by the smaller stack in #943 and #945. No commits were merged.

@harlan-zw harlan-zw closed this Aug 12, 2026
@harlan-zw
harlan-zw deleted the perf/ssr-render-hot-paths branch August 12, 2026 03:26
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