Skip to content

fix(unhead): throw a clean error for circular head input - #968

Draft
harlan-zw wants to merge 1 commit into
mainfrom
fix/walk-resolver-cycle
Draft

harlan-zw wants to merge 1 commit into
mainfrom
fix/walk-resolver-cycle

Conversation

@harlan-zw

@harlan-zw harlan-zw commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator

🔗 Linked issue

found while integrating unhead 3.4 streaming into Nuxt (nuxt/nuxt#36120, nuxt/nuxt#36139)

📚 Description

A circular reference in head input sent walkResolver into infinite recursion. The caller received Maximum call stack size exceeded ten frames deep in unhead internals, with nothing pointing at the user's input as the fault.

The walk now tracks ancestors in a Set and throws:

[unhead] Circular reference detected in head input at key "self". Remove the cycle, head tags must be a serialisable tree.

Sibling reuse stays legal: each object leaves the set when its subtree completes, so the same tag object may appear under two keys without a false positive. Structural sharing is untouched: unchanged input still returns the same reference.

Callers that already catch resolve failures keep working: renderSSRHeadSuspenseChunk drops the poisoned entry and rethrows, wrapStream's default flushChunk skips the patch, and the SSR render surfaces the clean message instead of a RangeError.

📝 Notes

  • one Set per top-level walk, allocated on first object entry; the static fast path still shares the whole tree
  • the children prop case from the same integration (renders as a literal attribute without the deprecations plugin) is already covered by the deprecated-prop-children rule, so no change there

Summary by CodeRabbit

  • Bug Fixes
    • Circular references in head configuration are now detected and reported with clear errors instead of causing indefinite recursion.
    • Nested and array-based cycles are handled safely while valid shared references continue to work.
    • Error messages identify the relevant head entry, including when resolution occurs during server-side rendering.

A cycle in head input sent walkResolver into infinite recursion, so the
caller got a RangeError ten frames deep in unhead internals. The walk now
tracks ancestors and throws an error naming the key the cycle entered
through. Siblings may share a reference, so each object leaves the set
when its subtree completes.
@coderabbitai

coderabbitai Bot commented Aug 22, 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: 368adff6-718e-453d-ae91-0eaa01b4e92c

📥 Commits

Reviewing files that changed from the base of the PR and between e69071e and 439739a.

📒 Files selected for processing (2)
  • packages/unhead/src/utils/walkResolver.ts
  • packages/unhead/test/unit/walkResolver.test.ts

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

walkResolver now detects circular arrays and plain objects during recursive head input traversal. It throws descriptive errors, preserves valid shared sibling references, and includes unit coverage for direct, nested, resolver, and SSR-rendered cycles.

Changes

Circular head input detection

Layer / File(s) Summary
Recursive cycle tracking
packages/unhead/src/utils/walkResolver.ts
walkResolver accepts an optional seen set. Array and plain-object traversal registers ancestors, propagates the set to recursive calls, and removes completed containers.
Cycle detection validation
packages/unhead/test/unit/walkResolver.test.ts
Tests cover cycle types, shared references, structural sharing, key-specific errors, resolver integration, and SSR error propagation.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 43973

This PR replaces an unhelpful recursion failure with a clear error for circular head input while preserving valid shared objects; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 2 files. 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 clearly and concisely describes the circular head input fix and follows the conventional commit format.
Description check ✅ Passed The description explains the problem, solution, behavior, tests, and SSR impact, but it omits the template's Type of change section.
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 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/walk-resolver-cycle

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.

@github-actions

Copy link
Copy Markdown
Contributor

📦 Bundle Size

⚠️ 12 bundles grew · net +1.8 kB gz

Bundle Gzipped Δ
Client (Minimal) 5.4 kB → 5.5 kB 🔴 +0.2 kB (+2.8%)
Client (Full) 9.6 kB → 9.8 kB 🔴 +0.1 kB (+1.5%)
Client (Self-Contained) 5.7 kB → 5.9 kB 🔴 +0.2 kB (+2.7%)
Server (Minimal) 4.9 kB → 5 kB 🔴 +0.2 kB (+3.3%)
Server (Self-Contained) 5.2 kB → 5.4 kB 🔴 +0.2 kB (+2.9%)
Vue Client (Minimal) 5.9 kB → 6 kB 🔴 +0.1 kB (+2.4%)
Vue Client (Full) 10.7 kB → 10.8 kB 🔴 +0.2 kB (+1.4%)
Vue Server (Minimal) 5.3 kB → 5.5 kB 🔴 +0.1 kB (+2.7%)
React Client (Minimal) 5.8 kB → 6 kB 🔴 +0.1 kB (+2.4%)
React Client (Full) 10.7 kB → 10.9 kB 🔴 +0.1 kB (+1.3%)
React Server (Minimal) 5.2 kB → 5.3 kB 🔴 +0.1 kB (+2.8%)
Schema.org (Minimal) 11.7 kB → 11.9 kB 🔴 +0.2 kB (+1.5%)
All bundles (14)
Bundle Gzipped Brotli Raw
Core
Client (Minimal) 5.5 kB 5 kB 13.7 kB 🔴
Client (Full) 9.8 kB 8.8 kB 25.3 kB 🔴
Client (Self-Contained) 5.9 kB 5.3 kB 14.5 kB 🔴
Server (Minimal) 5 kB 4.6 kB 12.6 kB 🔴
Server (Self-Contained) 5.4 kB 4.8 kB 13.2 kB 🔴
Vue
Vue Client (Minimal) 6 kB 5.4 kB 14.8 kB 🔴
Vue Client (Full) 10.8 kB 9.8 kB 28.1 kB 🔴
Vue Server (Minimal) 5.5 kB 5 kB 13.6 kB 🔴
React
React Client (Minimal) 6 kB 5.4 kB 14.9 kB 🔴
React Client (Full) 10.9 kB 9.9 kB 28.5 kB 🔴
React Server (Minimal) 5.3 kB 4.8 kB 13.2 kB 🔴
Schema.org
Schema.org (Minimal) 11.9 kB 10.7 kB 34.7 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)

⚠️ 1 slower · past the per-metric noise gate

Benchmark base → PR Δ
Streaming allocated / suspense chunk 4.9 KiB → 6.2 KiB 🔴 +1.3 KiB (+27.0%)
All benchmarks (25)
Benchmark PR Δ RME
SSR render (CPU) 0.446 ms ~ noise ±11.2%
SSR render (wall) 0.318 ms ~ noise ±5.5%
SSR allocated / render 262.6 KiB ~ noise ±4.5%
Schema.org cached render (CPU) 0.373 ms ~ noise ±5.4%
Schema.org cached render (wall) 0.268 ms ~ noise ±2.6%
Schema.org cached allocated / render 130.3 KiB ~ noise ±1.7%
Streaming wrapStream drain (CPU) 0.234 ms ~ noise ±3.8%
Streaming wrapStream drain (wall) 0.152 ms ~ noise ±4.3%
Streaming allocated / drain 151.9 KiB ℹ️ +5.0% ±0.3%
Streaming suspense chunk (CPU) 0.015 ms ~ noise ±9.7%
Streaming allocated / suspense chunk 6.2 KiB 🔴 +1.3 KiB (+27.0%) ±0.1%
CSR DOM mutations / nav 38 ~ noise —
CSR re-render (CPU) 1.078 ms ~ noise ±5.0%
CSR re-render (wall) 0.591 ms ~ noise ±1.8%
Bundler: transform id filter mixed ids 0.194 ms ~ noise ±0.5%
Bundler: useSeoMetaTransform static calls 3.652 ms ~ noise ±4.4%
Bundler: minifyTransform inline script/style 0.525 ms ~ noise ±3.5%
Bundler: treeshakeServerComposables many calls 2.658 ms ~ noise ±6.0%
Bundler: treeshakeServerComposables skip unrelated code 0.002 ms ~ noise ±1.0%
Bundler: ssrStaticReplace many head.ssr reads 1.648 ms ~ noise ±6.6%
Bundler: ssrStaticReplace skip unrelated code 0.002 ms ~ noise ±0.5%
Bundler: createHeadTransform many createHead calls 0.597 ms ~ noise ±5.2%
Bundler: react streaming skip JSX without head calls 0.003 ms ~ noise ±1.5%
Bundler: react streaming transform JSX with head calls 1.653 ms ~ noise ±7.8%
Bundler: solid streaming skip JSX without head calls 0.003 ms ~ noise ±1.4%

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

@harlan-zw harlan-zw added harlan-agent-running An Agent holds a Task on this issue or pull request right now. harlan-agent-review-required Pull request triage requires an adversarial Review for this head commit. labels Sep 28, 2026
@harlan-zw

harlan-zw commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

🤖 READY · 95/100

Harlan Agent Kit posted this automated review. It is not Harlan's personal review or approval. AI open source policy. A person still decides the merge.

  • Merge gate: Passed.
  • Review gate: Passed. No material issues.
  • CI gate: Passed.

@harlan-zw harlan-zw added harlan-agent-ready The automated Review passed every gate on this head commit. and removed harlan-agent-running An Agent holds a Task on this issue or pull request right now. harlan-agent-review-required Pull request triage requires an adversarial Review for this head commit. labels Sep 28, 2026
@harlan-zw
harlan-zw marked this pull request as draft September 29, 2026 13:12

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

harlan-agent-ready The automated Review passed every gate on this head commit.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant