perf: optimize SSR hot paths#794
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
π WalkthroughWalkthroughThe PR updates head input normalization, HTML extraction, tag normalization, serialization, duplicate-key detection, hook lookup, and resolve-flow assembly. ChangesHead pipeline
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
Estimated code review effortπ― 4 (Complex) | β±οΈ ~45 minutes Possibly related PRs
Poem
π₯ Pre-merge checks | β 4 | β 1β Failed checks (1 warning)
β Passed checks (4 passed)
β¨ Finishing Touchesπ§ͺ Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
π Files selected for processing (21)
bench/bundle/analyze.tsbench/bundle/last.jsonbench/bundle/schema-org-build.config.tsbench/bundle/src/schema-org/imports.tsbench/bundle/src/schema-org/vue-meta.tsbench/bundle/update.tsbench/memory/ssr.mjsbench/ssr-harlanzw-com-e2e.bench.tsbench/ssr-harlanzw-no-schema-e2e.bench.tsbench/ssr-memory.test.tsbench/utils/perf.tspackage.jsonpackages/unhead/src/composables.tspackages/unhead/src/parser/index.tspackages/unhead/src/plugins/flatMeta.tspackages/unhead/src/server/util/propsToString.tspackages/unhead/src/utils/dedupe.tspackages/unhead/src/utils/hooks.tspackages/unhead/src/utils/normalize.tspackages/unhead/src/utils/resolve.tspackages/vue/test/bench/ssr-harlanzw-no-schema-e2e.bench.ts
| 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) | ||
| } |
There was a problem hiding this comment.
π©Ί 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 20Repository: 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.
| 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) |
There was a problem hiding this comment.
π― 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 < 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.
β¦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).
* 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
4c0d74b to
3a1410a
Compare
π¦ Bundle Size
All bundles (12)
β‘ Performance (directional)π’ 1 faster
All benchmarks (3)
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.
3a1410a to
eb76aa0
Compare
β Type of change
π Description
Hot-path micro-optimizations across the SSR pipeline.
useSeoMetaflattening,FlatMetaPlugin,dedupe,normalize,resolve,propsToStringand the parser avoid intermediate spreads andfilter(Boolean)passes, andcallHookskips 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
classandstyle, yielding more consistent output and better hook-driven behavior.