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

Skip to content

perf: optimize SSR hot paths#794

Merged
harlan-zw merged 2 commits into
mainfrom
perf/ssr-hot-paths
Jun 27, 2026
Merged

perf: optimize SSR hot paths#794
harlan-zw merged 2 commits into
mainfrom
perf/ssr-hot-paths

Conversation

@harlan-zw

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

Copy link
Copy Markdown
Collaborator

❓ Type of change

  • πŸ“– Documentation
  • 🐞 Bug fix
  • πŸ‘Œ Enhancement
  • ✨ New feature
  • 🧹 Chore
  • ⚠️ Breaking change

πŸ“š Description

Hot-path micro-optimizations across the SSR pipeline. useSeoMeta flattening, FlatMetaPlugin, dedupe, normalize, resolve, propsToString and the parser avoid intermediate spreads and filter(Boolean) passes, and callHook skips dispatch when a hook has no listeners.

Scope is limited to packages/unhead/src/* (8 files); no behaviour change, no public API change. All bench tooling and the memory/size/perf reporting that validates these wins lives in #793, so the two PRs are independent and can merge in any order.

Summary by CodeRabbit

  • Bug Fixes
    • Improved normalization and resolution of title, meta, link, script, style, and base tags for more consistent head output.
    • Fixed edge cases where meta content provided as multiple values now renders as separate entries.
    • Enhanced flat-meta handling and meta deduplication to produce more reliable merged results.
    • Improved server-side string generation for class and style, yielding more consistent output and better hook-driven behavior.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▢️ Resume reviews
  • πŸ” Trigger review
πŸ“ Walkthrough

Walkthrough

The PR updates head input normalization, HTML extraction, tag normalization, serialization, duplicate-key detection, hook lookup, and resolve-flow assembly.

Changes

Head pipeline

Layer / File(s) Summary
Input normalization and extraction
packages/unhead/src/composables.ts, packages/unhead/src/parser/index.ts
useSeoMeta rebuilds _flatMeta by iterating input keys, entry.patch normalizes patched input, and HTML extraction uses a shared set of head elements.
Tag normalization and flat meta
packages/unhead/src/utils/normalize.ts, packages/unhead/src/plugins/flatMeta.ts
Array content values expand into multiple tags, normalized tags are appended directly, and _flatMeta entries are unpacked into meta tags.
Serialization, dedupe, and hook dispatch
packages/unhead/src/server/util/propsToString.ts, packages/unhead/src/utils/dedupe.ts, packages/unhead/src/utils/hooks.ts
Non-string class and style values use dedicated string helpers, isMetaArrayDupeKey slices colon-delimited keys, and callHook skips empty handler sets.
Resolve helpers
packages/unhead/src/utils/resolve.ts
pushEntryTags clones props.class and props.style on demand, valuesToTags rebuilds ctx.tags from tagMap values, and empty props checks avoid Object.keys allocation.
Resolve pipeline
packages/unhead/src/utils/resolve.ts
resolveTags normalizes entries, applies e.options, records ordering fields, caches normalized tags, and rebuilds the final tag list after dedupe and title resolution.

Sequence Diagram(s)

sequenceDiagram
  participant resolveTags
  participant entriesNormalize as entries:normalize
  participant pushEntryTags
  participant dedupeTags
  participant resolveTitleTemplate
  participant valuesToTags
  participant sanitizeTags
  resolveTags->>entriesNormalize: normalize each entry's tags
  entriesNormalize-->>resolveTags: normalized tags
  resolveTags->>pushEntryTags: append entryTags into ctx.tags
  resolveTags->>dedupeTags: dedupe ctx.tags
  resolveTags->>resolveTitleTemplate: apply title template
  resolveTags->>valuesToTags: rebuild ctx.tags from tagMap
  valuesToTags-->>resolveTags: final ctx.tags
  resolveTags->>sanitizeTags: sanitize ctx.tags
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • unjs/unhead#712: The hook lookup change in packages/unhead/src/utils/hooks.ts overlaps with hook dispatch behavior.
  • unjs/unhead#776: Also changes packages/unhead/src/utils/resolve.ts and overlaps with copy-on-write tag handling during resolution.
  • unjs/unhead#777: Also updates packages/unhead/src/utils/normalize.ts in the same normalization path.

Poem

A bunny hopped through tags at dawn,
With flat meta tidied, cleanly drawn.
I twitched my nose at hooks and strings,
And clapped my paws for sorted things. 🐰

πŸš₯ Pre-merge checks | βœ… 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.13% 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 is concise, conventional, and accurately summarizes the main change: SSR hot-path performance optimization.
Description check βœ… Passed The description includes the required change type and a detailed summary of the optimization scope and impact.
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.
✨ Finishing Touches
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/ssr-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: 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/hooks.ts`:
- Around line 13-18: The early-return in callHook is skipping Hookable’s
internal dispatch when there are no named listeners, which can bypass global
interceptors like beforeEach/afterEach. Update callHook so it always delegates
to head.hooks.callHook for real hook invocation, and remove the _hooks[hook]
length guard or replace it with a check that does not suppress Hookable’s global
interceptor path. Use callHook and head.hooks as the key symbols when locating
the fix.

In `@packages/unhead/src/utils/resolve.ts`:
- Around line 160-181: The cached tags in resolveTags are reusing stale _w
values when tagWeight changes because the weight is only computed inside the
e._tags initialization path. Update resolveTags in resolve.ts so the
weightFn-derived _w is recalculated on every call for existing e._tags, or make
the cache key account for the current weightFn, using the
resolveTags/weightFn/normalizeCtx flow to locate the fix.
πŸͺ„ 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: b05634f6-beb8-45bd-a614-f45a856de1e5

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between ba1d574 and a5bb4d5.

πŸ“’ Files selected for processing (21)
  • bench/bundle/analyze.ts
  • bench/bundle/last.json
  • bench/bundle/schema-org-build.config.ts
  • bench/bundle/src/schema-org/imports.ts
  • bench/bundle/src/schema-org/vue-meta.ts
  • bench/bundle/update.ts
  • bench/memory/ssr.mjs
  • bench/ssr-harlanzw-com-e2e.bench.ts
  • bench/ssr-harlanzw-no-schema-e2e.bench.ts
  • bench/ssr-memory.test.ts
  • bench/utils/perf.ts
  • package.json
  • packages/unhead/src/composables.ts
  • packages/unhead/src/parser/index.ts
  • packages/unhead/src/plugins/flatMeta.ts
  • packages/unhead/src/server/util/propsToString.ts
  • packages/unhead/src/utils/dedupe.ts
  • packages/unhead/src/utils/hooks.ts
  • packages/unhead/src/utils/normalize.ts
  • packages/unhead/src/utils/resolve.ts
  • packages/vue/test/bench/ssr-harlanzw-no-schema-e2e.bench.ts

Comment on lines 13 to 18
export function callHook(head: Unhead<any, any>, hook: string, ctx: any) {
const hooks = (head.hooks as any)?._hooks?.[hook]
if (!hooks?.length)
return
return head.hooks?.callHook(hook as any, ctx)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for global Hookable interceptor usage that the early-return could skip
rg -nP '\.(beforeEach|afterEach)\s*\(' --type=ts -g '!**/node_modules/**'
# Confirm Hookable exposes internal _hooks (and not _before/_after only) in the installed version
fd -t f 'package.json' -d 3 node_modules/hookable 2>/dev/null -x cat {} \; | jq -r '.version' 2>/dev/null
rg -nP '_hooks|_before|_after' node_modules/hookable/dist/index.mjs 2>/dev/null | head -n 20

Repository: unjs/unhead

Length of output: 149


Confirm early-return logic bypasses Hookable global interceptors.

The current implementation checks for named listeners in head.hooks._hooks[hook] before calling head.hooks.callHook. In Hookable, global interceptors (beforeEach/afterEach) are distinct from named listeners. If a hook has no named listeners in _hooks, this guard exits early, preventing the internal dispatch mechanism from ever triggering for those global interceptors. This changes behavior if any part of the unhead ecosystem relies on these global hooks being invoked even for "empty" event keys.

πŸ€– 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/hooks.ts` around lines 13 - 18, The early-return in
callHook is skipping Hookable’s internal dispatch when there are no named
listeners, which can bypass global interceptors like beforeEach/afterEach.
Update callHook so it always delegates to head.hooks.callHook for real hook
invocation, and remove the _hooks[hook] length guard or replace it with a check
that does not suppress Hookable’s global interceptor path. Use callHook and
head.hooks as the key symbols when locating the fix.

Comment on lines +160 to +181
const entryTags: HeadTag[][] = []
for (const e of entries) {
if (!e._tags) {
const tags = normalizeEntryToTags(e.input, head.resolvedOptions.propResolvers || [])
for (const t of tags)
Object.assign(t, e.options)
const normalizeCtx = {
tags: normalizeEntryToTags(e.input, head.resolvedOptions.propResolvers || []).map(t => Object.assign(t, e.options)),
tags,
entry: e,
}
callHook(head, 'entries:normalize', normalizeCtx)
e._tags = normalizeCtx.tags.map((t, i) => {
for (let i = 0; i < normalizeCtx.tags.length; i++) {
const t = normalizeCtx.tags[i]
t._w = weightFn(t)
t._p = (e._i << 10) + i
t._d = dedupeKey(t)
if (!t._d)
t._h = hashTag(t)
return t
})
}
e._tags = normalizeCtx.tags
}
entryTags.push(e._tags)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Compare current _w/_p assignment placement against the previous version
fd -p 'packages/unhead/src/utils/resolve.ts' -x git log -n 3 --oneline -- {} \;
git show HEAD~1:packages/unhead/src/utils/resolve.ts 2>/dev/null | rg -nP '_w\s*=|_tags|weightFn|tagWeight' 
# Find callers passing a custom tagWeight option
rg -nP 'resolveTags\s*\(' --type=ts -A2 -g '!**/node_modules/**'
rg -nP 'tagWeight' --type=ts -g '!**/node_modules/**'

Repository: unjs/unhead

Length of output: 183


Regression: Cached entry tags retain stale _w values when tagWeight changes

The weight t._w is computed and cached on e._tags during the first resolveTags call. A subsequent call providing a different tagWeight function via options bypasses the calculation block because the cache exists, causing the custom weight to be silently ignored for already normalized entries.

  for (const e of entries) {
    if (!e._tags) {
      // ... normalization ...
      for (let i = 0; i &lt; normalizeCtx.tags.length; i++) {
        const t = normalizeCtx.tags[i]
        t._w = weightFn(t) // computed once, then cached
        // ...

Recalculate _w on every invocation or include the weightFn identity in the cache key to ensure custom weights are honored.

πŸ€– 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/resolve.ts` around lines 160 - 181, The cached tags
in resolveTags are reusing stale _w values when tagWeight changes because the
weight is only computed inside the e._tags initialization path. Update
resolveTags in resolve.ts so the weightFn-derived _w is recalculated on every
call for existing e._tags, or make the cache key account for the current
weightFn, using the resolveTags/weightFn/normalizeCtx flow to locate the fix.

harlan-zw added a commit that referenced this pull request Jun 27, 2026
…infra PR

Consolidates all benchmark + CI-reporting infrastructure here so the combined
size+perf report has its inputs in one place. The memory harness (ssr.mjs,
perf.ts, ssr-memory.test.ts), the e2e bench async->sync tweaks, and the
test:memory script move out of the src-only perf PR (#794).
@harlan-zw harlan-zw changed the title perf: optimize SSR hot paths, add memory and schema-org bundle tracking perf: optimize SSR hot paths Jun 27, 2026
harlan-zw added a commit that referenced this pull request Jun 27, 2026
* ci: track schema-org bundle size

* feat(bench): add schema-org imports and vue-meta bundle sources

Tracks the schema-org client bundle (minimal, imports, vue-meta) in the
bundle-size analyzer and stats writer so the schema-org surface is reported
alongside core and Vue. Pairs with the workflow steps already in this PR.

* fix(bench): address review on schema-org bundle reporting

- analyze.ts: always emit the schema-org rows when the PR builds those bundles,
  falling back to a 0 baseline when the base ref lacks them (read base files
  defensively instead of throwing on a missing path)
- bundle-size.yml: tolerate a base ref that predates the schema-org build config,
  pass the imports/vue-meta baselines, and pin setup-bun to a commit SHA

* feat(bench): redesign the bundle-size PR comment

- verdict line up top (grew / smaller / no notable changes + new-bundle count)
- only changed bundles surface in a top table; full per-category breakdown is
  collapsed in a <details>
- a missing baseline now reads as a πŸ†• new bundle instead of a red +X regression
- sub-100-byte gz deltas render in bytes so they don't collapse to '0 kB'
- footer shows the baseline ref + commit + date for cross-PR context
- noise floor (16 B gz) keeps minifier jitter from showing as a change

* chore(bench): bring memory harness + e2e bench tweaks into the bench infra PR

Consolidates all benchmark + CI-reporting infrastructure here so the combined
size+perf report has its inputs in one place. The memory harness (ssr.mjs,
perf.ts, ssr-memory.test.ts), the e2e bench async->sync tweaks, and the
test:memory script move out of the src-only perf PR (#794).

* feat(bench): manifest-driven bundle tracking + React and Full profiles

- bundles.ts: single source of truth (id, name, category, dist path) that
  analyze.ts and update.ts both iterate; adding a bundle is now one entry
- bundle-report.ts: pure collect + render module (no import-time side effects)
  so the combined report can reuse it; analyze.ts is a thin runner
- CI now diffs the PR dist against a full base-branch dist via BASE_DIST instead
  of per-bundle positional args
- add Full profiles (useHead + useSeoMeta + useScript) for core/vue/react as
  standalone single-entry builds, the only entry that catches useSeoMeta/useScript
  growth since the minimal floor tree-shakes them away
- add React client+server tracking to mirror Vue
- regenerate last.json baseline with all 12 bundles

* ci: build all bundles and diff against a base-dist snapshot

- bundle-size.yml: build core/vue/react/schema-org for base and PR; snapshot the
  base dist to /tmp/base-dist and pass it via BASE_DIST (replaces per-bundle cp +
  positional args). Base build tolerates configs the base ref predates.
- write-bundle-size.yml: build react bundles before updating last.json; pin setup-bun

* chore(lint): exempt node bench harnesses, fix perf util + memory test

- ignore bench/**/*.mjs (standalone --expose-gc harnesses that import built dist,
  use process/console and top-level await by design)
- perf.ts: import process from node:process
- ssr-memory.test.ts: allow the informational console.table

* feat(bench): add gated, directional performance section to the PR comment

- perf-ci.mjs: standalone --expose-gc harness; prints JSON for SSR render wall-time
  (mean ms + 95% RME over sampled batches) and retained heap over a fixed run
- perf-report.ts: only surfaces a change past the gate (time |Ξ”| > max(10%, 2Γ—RME);
  memory |Ξ”| > 512 KiB, since retained heap hovers near zero), framed as directional
- report.ts: one combined comment (size + perf), replacing analyze.ts as the CI
  comment generator (analyze.ts stays for local bundle-only runs)
- bundle-size.yml: run the harness on base and PR (same runner) and feed both JSONs
  to report.ts via BASE_PERF / PR_PERF
@harlan-zw harlan-zw force-pushed the perf/ssr-hot-paths branch 3 times, most recently from 4c0d74b to 3a1410a Compare June 27, 2026 03:33
@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

πŸ“¦ Bundle Size

⚠️ 10 bundles grew · net +1.1 kB gz

Bundle Gzipped Ξ”
Client (Minimal) 4.6 kB β†’ 4.7 kB πŸ”΄ +95 B (+2.0%)
Client (Full) 7.8 kB β†’ 7.9 kB πŸ”΄ +0.1 kB (+1.6%)
Server (Minimal) 4.4 kB β†’ 4.5 kB πŸ”΄ +0.1 kB (+2.6%)
Vue Client (Minimal) 5.1 kB β†’ 5.2 kB πŸ”΄ +96 B (+1.8%)
Vue Client (Full) 8.6 kB β†’ 8.7 kB πŸ”΄ +0.1 kB (+1.2%)
Vue Server (Minimal) 4.9 kB β†’ 5 kB πŸ”΄ +0.1 kB (+2.4%)
React Client (Minimal) 5 kB β†’ 5.1 kB πŸ”΄ +0.1 kB (+2.0%)
React Client (Full) 8.4 kB β†’ 8.5 kB πŸ”΄ +0.1 kB (+1.4%)
React Server (Minimal) 4.7 kB β†’ 4.8 kB πŸ”΄ +0.1 kB (+2.4%)
Schema.org (Minimal) 9.4 kB β†’ 9.5 kB πŸ”΄ +0.1 kB (+1.1%)
All bundles (12)
Bundle Gzipped Raw
Core
Client (Minimal) 4.7 kB 11.7 kB πŸ”΄
Client (Full) 7.9 kB 20.6 kB πŸ”΄
Server (Minimal) 4.5 kB 11.4 kB πŸ”΄
Vue
Vue Client (Minimal) 5.2 kB 12.8 kB πŸ”΄
Vue Client (Full) 8.7 kB 22.5 kB πŸ”΄
Vue Server (Minimal) 5 kB 12.3 kB πŸ”΄
React
React Client (Minimal) 5.1 kB 12.7 kB πŸ”΄
React Client (Full) 8.5 kB 22.1 kB πŸ”΄
React Server (Minimal) 4.8 kB 12 kB πŸ”΄
Schema.org
Schema.org (Minimal) 9.5 kB 26.3 kB πŸ”΄
Schema.org Imports 0.1 kB 0.1 kB βœ…
Schema.org Vue Meta 0.4 kB 0.8 kB βœ…

⚑ Performance (directional)

🟒 1 faster

Benchmark base β†’ PR Ξ”
SSR allocated / render 374.4 KiB β†’ 321.9 KiB 🟒 -52.5 KiB (-14.0%)
All benchmarks (3)
Benchmark PR Ξ” RME
SSR render (CPU) 0.445 ms ~ noise Β±8.4%
SSR render (wall) 0.287 ms ~ noise Β±4.3%
SSR allocated / render 321.9 KiB 🟒 -52.5 KiB (-14.0%) β€”

Baseline: main @ 668420a Β· 2026-06-27 Β· gzipped is the headline size metric Β· perf is directional (shared-runner, gated)

Hot-path micro-optimizations across the SSR pipeline: useSeoMeta flattening,
FlatMetaPlugin, dedupe, normalize, resolve, propsToString and the parser avoid
intermediate spreads and filter(Boolean) passes, and callHook skips dispatch
when a hook has no listeners.
Runs per final tag; an empty-object test via for-in early-return avoids allocating
a keys array each call.
@harlan-zw harlan-zw force-pushed the perf/ssr-hot-paths branch from 3a1410a to eb76aa0 Compare June 27, 2026 04:04
@harlan-zw harlan-zw merged commit f806f81 into main Jun 27, 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