Skip to content

Fix(head-content-utils): deduplicate specific link tags (e.g. canonical) to allow child overrides#6761

Open
jong-kyung wants to merge 6 commits intoTanStack:mainfrom
jong-kyung:fix/heda-content-utils
Open

Fix(head-content-utils): deduplicate specific link tags (e.g. canonical) to allow child overrides#6761
jong-kyung wants to merge 6 commits intoTanStack:mainfrom
jong-kyung:fix/heda-content-utils

Conversation

@jong-kyung
Copy link
Contributor

@jong-kyung jong-kyung commented Feb 25, 2026

Closes #6719

Description

This PR fixes an issue where <link> tags defined in child routes (specifically rel="canonical") could not override parent route links, resulting in duplicated output in the <head> which is harmful for SEO.

Changes

To solve this safely without introducing destructive side-effects (such as accidentally removing multiple stylesheet or preload links):

  1. Selective Deduplication: Introduced a relsToDedupe Set. Currently, it only targets canonical.
  2. Reverse Iteration: Replicated the robust reverse-iteration logic already used for meta tags (traversing from the deepest child to the root). This ensures the child route's link naturally takes precedence.
  3. Performance: Used an O(1) lookup record (linksByRel) for deduplication to ensure no performance degradation during the render cycle.

Summary by CodeRabbit

  • New Features

    • Added canonical pages at /canonical and /canonical/deep across example apps; routes are registered and exposed in the public route surfaces.
    • Head generation now deduplicates and deterministically orders canonical link tags so only one rel="canonical" appears.
  • Tests

    • Added e2e tests verifying a single canonical link and correct href for /canonical/deep across frameworks.
  • Refactor

    • Reworked link/head assembly to support canonical deduplication and stable ordering.

@jong-kyung jong-kyung changed the title Fix/heda content utils Fix/head content utils Feb 25, 2026
@nx-cloud
Copy link

nx-cloud bot commented Feb 25, 2026

🤖 Nx Cloud AI Fix Eligible

An automatically generated fix could have helped fix failing tasks for this run, but Self-healing CI is disabled for this workspace. Visit workspace settings to enable it and get automatic fixes in future runs.

To disable these notifications, a workspace admin can disable them in workspace settings.


View your CI Pipeline Execution ↗ for commit 3f360b4

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ❌ Failed 9m 45s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 4s View ↗

☁️ Nx Cloud last updated this comment at 2026-03-07 14:47:23 UTC

@jong-kyung jong-kyung changed the title Fix/head content utils Fix(head-content-utils): deduplicate specific link tags (e.g. canonical) to allow child overrides Feb 25, 2026
@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 25, 2026

More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@6761

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@6761

@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@6761

@tanstack/nitro-v2-vite-plugin

npm i https://pkg.pr.new/@tanstack/nitro-v2-vite-plugin@6761

@tanstack/react-router

npm i https://pkg.pr.new/@tanstack/react-router@6761

@tanstack/react-router-devtools

npm i https://pkg.pr.new/@tanstack/react-router-devtools@6761

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/@tanstack/react-router-ssr-query@6761

@tanstack/react-start

npm i https://pkg.pr.new/@tanstack/react-start@6761

@tanstack/react-start-client

npm i https://pkg.pr.new/@tanstack/react-start-client@6761

@tanstack/react-start-server

npm i https://pkg.pr.new/@tanstack/react-start-server@6761

@tanstack/router-cli

npm i https://pkg.pr.new/@tanstack/router-cli@6761

@tanstack/router-core

npm i https://pkg.pr.new/@tanstack/router-core@6761

@tanstack/router-devtools

npm i https://pkg.pr.new/@tanstack/router-devtools@6761

@tanstack/router-devtools-core

npm i https://pkg.pr.new/@tanstack/router-devtools-core@6761

@tanstack/router-generator

npm i https://pkg.pr.new/@tanstack/router-generator@6761

@tanstack/router-plugin

npm i https://pkg.pr.new/@tanstack/router-plugin@6761

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/@tanstack/router-ssr-query-core@6761

@tanstack/router-utils

npm i https://pkg.pr.new/@tanstack/router-utils@6761

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/@tanstack/router-vite-plugin@6761

@tanstack/solid-router

npm i https://pkg.pr.new/@tanstack/solid-router@6761

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/@tanstack/solid-router-devtools@6761

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/@tanstack/solid-router-ssr-query@6761

@tanstack/solid-start

npm i https://pkg.pr.new/@tanstack/solid-start@6761

@tanstack/solid-start-client

npm i https://pkg.pr.new/@tanstack/solid-start-client@6761

@tanstack/solid-start-server

npm i https://pkg.pr.new/@tanstack/solid-start-server@6761

@tanstack/start-client-core

npm i https://pkg.pr.new/@tanstack/start-client-core@6761

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/@tanstack/start-fn-stubs@6761

@tanstack/start-plugin-core

npm i https://pkg.pr.new/@tanstack/start-plugin-core@6761

@tanstack/start-server-core

npm i https://pkg.pr.new/@tanstack/start-server-core@6761

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/@tanstack/start-static-server-functions@6761

@tanstack/start-storage-context

npm i https://pkg.pr.new/@tanstack/start-storage-context@6761

@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@6761

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@6761

@tanstack/vue-router

npm i https://pkg.pr.new/@tanstack/vue-router@6761

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/@tanstack/vue-router-devtools@6761

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/@tanstack/vue-router-ssr-query@6761

@tanstack/vue-start

npm i https://pkg.pr.new/@tanstack/vue-start@6761

@tanstack/vue-start-client

npm i https://pkg.pr.new/@tanstack/vue-start-client@6761

@tanstack/vue-start-server

npm i https://pkg.pr.new/@tanstack/vue-start-server@6761

@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@6761

commit: 3a1c341

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 25, 2026

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

Adds /canonical and /canonical/deep file routes and E2E tests for React, Solid, and Vue starters; refactors router head assembly to iterate matches deepest-first, deduplicate link tags by rel (so child canonical overrides parent), inject nonces, and produce deterministic ordering.

Changes

Cohort / File(s) Summary
React — Routes & Generated Tree
e2e/react-start/basic/src/routes/canonical/route.tsx, e2e/react-start/basic/src/routes/canonical/deep/route.tsx, e2e/react-start/basic/src/routeTree.gen.ts
Adds /canonical and /canonical/deep file routes with canonical head links; wires generated route tree, children types, and public exports.
React — E2E
e2e/react-start/basic/tests/canonical.spec.ts
Adds test navigating to /canonical/deep, asserts a single <link rel="canonical">, and verifies its href.
Solid — Routes & Generated Tree
e2e/solid-start/basic/src/routes/canonical/route.tsx, e2e/solid-start/basic/src/routes/canonical/deep/route.tsx, e2e/solid-start/basic/src/routeTree.gen.ts
Adds /canonical and /canonical/deep Solid file routes with canonical head links; updates generated route tree, parent relationships, and public type maps.
Solid — E2E
e2e/solid-start/basic/tests/canonical.spec.ts
Adds test verifying canonical link deduplication and correct href at /canonical/deep.
Vue — Routes & Generated Tree
e2e/vue-start/basic/src/routes/canonical/route.tsx, e2e/vue-start/basic/src/routes/canonical/deep/route.tsx, e2e/vue-start/basic/src/routeTree.gen.ts
Adds /canonical and /canonical/deep Vue file routes; updates generated route tree, public exports, and type mappings.
Vue — E2E
e2e/vue-start/basic/tests/canonical.spec.ts
Adds test ensuring a single canonical link exists at /canonical/deep with correct href.
Router Packages — Head utils
packages/react-router/src/headContentUtils.tsx, packages/solid-router/src/headContentUtils.tsx, packages/vue-router/src/headContentUtils.tsx
Refactors link/tag assembly to iterate matches deepest-first, deduplicate by rel (notably canonical) so child wins, attach nonce to tags, then reverse for final deterministic order; replaces prior flat-map/flatten approach.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • nlynzaad
  • birkskyum
  • Sheraff

Poem

🐇 I hopped through routes both near and deep,

Cleared extra links so head tags sleep,
Child canonical now wears the crown,
Parent duplicates tumble down.
Hooray — tidy heads and happy routes!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main objective: fixing deduplication of specific link tags (canonical) to allow child routes to override parent routes.
Linked Issues check ✅ Passed The PR successfully addresses all coding requirements from issue #6719: implements rel-specific deduplication, processes routes in reverse order for child precedence, uses O(1) lookup for efficiency, and adds E2E test coverage across all router packages.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the linked issue: route deduplication logic updates, test files for canonical links, and generated route tree files are all in support of the primary objective.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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.

@jong-kyung jong-kyung force-pushed the fix/heda-content-utils branch from 45f2e6b to b55ac74 Compare February 25, 2026 11:47
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/react-router/src/headContentUtils.tsx (1)

94-95: Consider hoisting relsToDedupe outside the select callback.

relsToDedupe is a constant Set that gets re-created on every state change. Hoisting it to module scope (or at least outside select) avoids unnecessary allocations on the hot path.

♻️ Proposed refactor
+const relsToDedupe = new Set(['canonical'])
+
 export const useTags = () => {

Then remove the local declaration inside select:

       const constructedLinks: Array<RouterManagedTag> = []
-      const relsToDedupe = new Set(['canonical'])
       const linksByRel: Record<string, true> = {}

This applies equally to the Solid and Vue implementations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router/src/headContentUtils.tsx` around lines 94 - 95, Hoist
the constant Set relsToDedupe out of the select callback (move it to module
scope or at least above the select invocation) so it isn't re-created on every
state change; remove the local declaration inside the select callback and keep
using the same relsToDedupe in headContentUtils.tsx (and mirror the same change
in the Solid and Vue variants) to avoid unnecessary allocations on the hot path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react-router/src/headContentUtils.tsx`:
- Around line 94-95: Hoist the constant Set relsToDedupe out of the select
callback (move it to module scope or at least above the select invocation) so it
isn't re-created on every state change; remove the local declaration inside the
select callback and keep using the same relsToDedupe in headContentUtils.tsx
(and mirror the same change in the Solid and Vue variants) to avoid unnecessary
allocations on the hot path.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c4027b6 and 45f2e6b.

📒 Files selected for processing (15)
  • e2e/react-start/basic/src/routeTree.gen.ts
  • e2e/react-start/basic/src/routes/canonical/deep/route.tsx
  • e2e/react-start/basic/src/routes/canonical/route.tsx
  • e2e/react-start/basic/tests/canonical.spec.ts
  • e2e/solid-start/basic/src/routeTree.gen.ts
  • e2e/solid-start/basic/src/routes/canonical/deep/route.tsx
  • e2e/solid-start/basic/src/routes/canonical/route.tsx
  • e2e/solid-start/basic/tests/canonical.spec.ts
  • e2e/vue-start/basic/src/routeTree.gen.ts
  • e2e/vue-start/basic/src/routes/canonical/deep/route.tsx
  • e2e/vue-start/basic/src/routes/canonical/route.tsx
  • e2e/vue-start/basic/tests/canonical.spec.ts
  • packages/react-router/src/headContentUtils.tsx
  • packages/solid-router/src/headContentUtils.tsx
  • packages/vue-router/src/headContentUtils.tsx

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
packages/vue-router/src/headContentUtils.tsx (1)

77-107: Link deduplication logic looks correct — child canonical properly overrides parent.

The reverse iteration + final .reverse() mirrors the existing meta tag strategy (line 21), and the linksByRel record provides the O(1) lookup as intended. Implementation is clean and consistent.

One optional refinement: relsToDedupe is a static constant recreated on every selector invocation. Hoisting it to module scope avoids the (tiny) repeated allocation and makes the "configuration" more discoverable.

♻️ Optional: hoist relsToDedupe to module scope
+const RELS_TO_DEDUPE = new Set(['canonical'])
+
 export const useTags = () => {

Then inside the selector:

-      const relsToDedupe = new Set(['canonical'])
       ...
-          if (link.rel && relsToDedupe.has(link.rel)) {
+          if (link.rel && RELS_TO_DEDUPE.has(link.rel)) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/vue-router/src/headContentUtils.tsx` around lines 77 - 107, Hoist
the static relsToDedupe Set out of the selector to module scope to avoid
recreating it on every invocation: create a top-level constant (e.g.
RELS_TO_DEDUPE = new Set(['canonical'])) and replace the local relsToDedupe
usage inside the select function with this constant; keep the rest of the logic
in select (references: select, linksByRel, RouterManagedTag, state.matches)
unchanged so deduplication behavior remains identical.
packages/solid-router/src/headContentUtils.tsx (1)

94-123: Implementation is correct and consistent with Vue and React counterparts.

The deduplication logic using relsToDedupe and linksByRel is identical across all three packages (solid-router, vue-router, react-router). Reverse iteration gives child precedence, linksByRel provides O(1) dedup lookup, and the final .reverse() restores document order. The nonce inclusion in attrs is consistent with how this file handles other tags.

Optional optimization: Consider hoisting relsToDedupe to module scope across all three implementations (solid-router, vue-router, react-router). The new Set(['canonical']) is recreated on every selector invocation despite containing a static value. Moving it to module scope would eliminate this overhead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/solid-router/src/headContentUtils.tsx` around lines 94 - 123, Move
the static dedupe set out of the selector function and into module scope:
replace the inline creation of relsToDedupe (new Set(['canonical'])) with a
shared top-level constant (e.g., const RELS_TO_DEDUPE = new Set(['canonical']))
and update the code that currently references relsToDedupe to use RELS_TO_DEDUPE
instead; ensure this change is applied consistently in
packages/solid-router/src/headContentUtils.tsx (and mirror the same hoist in
vue-router and react-router implementations) so constructedLinks, linksByRel,
and the reverse/iteration behavior remain unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/solid-router/src/headContentUtils.tsx`:
- Around line 94-123: Move the static dedupe set out of the selector function
and into module scope: replace the inline creation of relsToDedupe (new
Set(['canonical'])) with a shared top-level constant (e.g., const RELS_TO_DEDUPE
= new Set(['canonical'])) and update the code that currently references
relsToDedupe to use RELS_TO_DEDUPE instead; ensure this change is applied
consistently in packages/solid-router/src/headContentUtils.tsx (and mirror the
same hoist in vue-router and react-router implementations) so constructedLinks,
linksByRel, and the reverse/iteration behavior remain unchanged.

In `@packages/vue-router/src/headContentUtils.tsx`:
- Around line 77-107: Hoist the static relsToDedupe Set out of the selector to
module scope to avoid recreating it on every invocation: create a top-level
constant (e.g. RELS_TO_DEDUPE = new Set(['canonical'])) and replace the local
relsToDedupe usage inside the select function with this constant; keep the rest
of the logic in select (references: select, linksByRel, RouterManagedTag,
state.matches) unchanged so deduplication behavior remains identical.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 45f2e6b and b55ac74.

📒 Files selected for processing (3)
  • packages/react-router/src/headContentUtils.tsx
  • packages/solid-router/src/headContentUtils.tsx
  • packages/vue-router/src/headContentUtils.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-router/src/headContentUtils.tsx

@jong-kyung jong-kyung force-pushed the fix/heda-content-utils branch from b55ac74 to 7e91c99 Compare February 25, 2026 12:15
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

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 (1)
e2e/solid-start/basic/tests/canonical.spec.ts (1)

8-12: Redundant count assertion — remove the links collection block.

Line 8's toHaveCount(1) (Playwright async assertion with built-in retry) already verifies there is exactly one canonical link. Lines 11–12 collect the same locator into an array and repeat the same assertion synchronously, adding no extra coverage.

🧹 Proposed cleanup
  await expect(page.locator('link[rel="canonical"]')).toHaveCount(1)

-  // Get all canonical links
-  const links = await page.locator('link[rel="canonical"]').all()
-  expect(links).toHaveLength(1)
-
  await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/solid-start/basic/tests/canonical.spec.ts` around lines 8 - 12, Remove
the redundant synchronous collection and assertion: delete the lines that assign
const links = await page.locator('link[rel="canonical"]').all() and the
subsequent expect(links).toHaveLength(1). Keep the existing Playwright async
assertion await expect(page.locator('link[rel="canonical"]')).toHaveCount(1)
which already verifies there is exactly one canonical link.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/react-router/src/headContentUtils.tsx`:
- Around line 104-109: The dedupe check uses relsToDedupe.has(link.rel) which is
case-sensitive; normalize link.rel (e.g., const normalizedRel = (link.rel ||
"").toLowerCase().trim()) before checking and when setting linksByRel to ensure
"Canonical" and "canonical" are treated the same; update the if to use
normalizedRel for relsToDedupe.has(...) and for linksByRel[normalizedRel] = true
while preserving the original link.rel value elsewhere if needed.

In `@packages/vue-router/src/headContentUtils.tsx`:
- Around line 89-94: Normalize link.rel (e.g., const normalizedRel =
link.rel.trim().toLowerCase()) before checking against relsToDedupe and before
indexing linksByRel so variants like "Canonical" or " canonical " are
deduplicated; update the block that references link.rel, relsToDedupe, and
linksByRel in headContentUtils.tsx to use the normalizedRel for both the
has-check and assignment.

---

Nitpick comments:
In `@e2e/solid-start/basic/tests/canonical.spec.ts`:
- Around line 8-12: Remove the redundant synchronous collection and assertion:
delete the lines that assign const links = await
page.locator('link[rel="canonical"]').all() and the subsequent
expect(links).toHaveLength(1). Keep the existing Playwright async assertion
await expect(page.locator('link[rel="canonical"]')).toHaveCount(1) which already
verifies there is exactly one canonical link.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b55ac74 and 7e91c99.

📒 Files selected for processing (15)
  • e2e/react-start/basic/src/routeTree.gen.ts
  • e2e/react-start/basic/src/routes/canonical/deep/route.tsx
  • e2e/react-start/basic/src/routes/canonical/route.tsx
  • e2e/react-start/basic/tests/canonical.spec.ts
  • e2e/solid-start/basic/src/routeTree.gen.ts
  • e2e/solid-start/basic/src/routes/canonical/deep/route.tsx
  • e2e/solid-start/basic/src/routes/canonical/route.tsx
  • e2e/solid-start/basic/tests/canonical.spec.ts
  • e2e/vue-start/basic/src/routeTree.gen.ts
  • e2e/vue-start/basic/src/routes/canonical/deep/route.tsx
  • e2e/vue-start/basic/src/routes/canonical/route.tsx
  • e2e/vue-start/basic/tests/canonical.spec.ts
  • packages/react-router/src/headContentUtils.tsx
  • packages/solid-router/src/headContentUtils.tsx
  • packages/vue-router/src/headContentUtils.tsx
🚧 Files skipped from review as they are similar to previous changes (8)
  • e2e/solid-start/basic/src/routes/canonical/deep/route.tsx
  • packages/solid-router/src/headContentUtils.tsx
  • e2e/vue-start/basic/src/routes/canonical/deep/route.tsx
  • e2e/vue-start/basic/src/routes/canonical/route.tsx
  • e2e/solid-start/basic/src/routes/canonical/route.tsx
  • e2e/react-start/basic/src/routes/canonical/deep/route.tsx
  • e2e/vue-start/basic/tests/canonical.spec.ts
  • e2e/react-start/basic/tests/canonical.spec.ts

Comment on lines +89 to +94
if (link.rel && relsToDedupe.has(link.rel)) {
if (linksByRel[link.rel]) {
continue
}
linksByRel[link.rel] = true
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Normalize rel before dedupe to avoid canonical duplicates from casing/whitespace variants.

link.rel is matched as-is, so "Canonical" or " canonical " won’t dedupe and can reintroduce duplicate canonicals.

Suggested fix
-          if (link.rel && relsToDedupe.has(link.rel)) {
-            if (linksByRel[link.rel]) {
+          const normalizedRel = link.rel?.trim().toLowerCase()
+          if (normalizedRel && relsToDedupe.has(normalizedRel)) {
+            if (linksByRel[normalizedRel]) {
               continue
             }
-            linksByRel[link.rel] = true
+            linksByRel[normalizedRel] = true
           }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (link.rel && relsToDedupe.has(link.rel)) {
if (linksByRel[link.rel]) {
continue
}
linksByRel[link.rel] = true
}
const normalizedRel = link.rel?.trim().toLowerCase()
if (normalizedRel && relsToDedupe.has(normalizedRel)) {
if (linksByRel[normalizedRel]) {
continue
}
linksByRel[normalizedRel] = true
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/vue-router/src/headContentUtils.tsx` around lines 89 - 94, Normalize
link.rel (e.g., const normalizedRel = link.rel.trim().toLowerCase()) before
checking against relsToDedupe and before indexing linksByRel so variants like
"Canonical" or " canonical " are deduplicated; update the block that references
link.rel, relsToDedupe, and linksByRel in headContentUtils.tsx to use the
normalizedRel for both the has-check and assignment.

@jong-kyung
Copy link
Contributor Author

jong-kyung commented Feb 25, 2026

While testing the fix in the vue-start environment, I noticed that the entire head content is being duplicated during SSR/Hydration (everything from <title> to appears twice in the DOM).

This duplication is causing the E2E tests to fail, as they detect two canonical links instead of one. I'm currently investigating @tanstack/vue-router to see if this is an issue with the HeadContent component or the hydration process in the Vue adapter.

image

@jong-kyung
Copy link
Contributor Author

While testing the fix in the vue-start environment, I noticed that the entire head content is being duplicated during SSR/Hydration (everything from <title> to appears twice in the DOM).

This duplication is causing the E2E tests to fail, as they detect two canonical links instead of one. I'm currently investigating @tanstack/vue-router to see if this is an issue with the HeadContent component or the hydration process in the Vue adapter.

image

I created an issue #6764 related to this.

@github-actions
Copy link

github-actions bot commented Mar 2, 2026

Bundle Size Benchmarks

  • Commit: 4ea2a62787d0
  • Measured at: 2026-03-07T14:38:27.782Z
  • Baseline source: history:4ea2a62787d0
  • Dashboard: bundle-size history
Scenario Current (gzip) Delta vs baseline Raw Brotli Trend
react-router.minimal 86.84 KiB 0 B (0.00%) 273.33 KiB 75.44 KiB ▁▁▁▁▁██████
react-router.full 89.99 KiB +106 B (+0.12%) 283.87 KiB 78.31 KiB ▁▁▁▁▁▆▆▆▆▆▆█
solid-router.minimal 36.17 KiB 0 B (0.00%) 108.44 KiB 32.49 KiB ▁▁▁▁▁██████
solid-router.full 40.59 KiB +93 B (+0.22%) 121.69 KiB 36.45 KiB ▁▁▁▁▁▆▆▆▆▆▆█
vue-router.minimal 52.03 KiB 0 B (0.00%) 148.42 KiB 46.71 KiB ▁▁▁▁▁██████
vue-router.full 56.95 KiB +111 B (+0.19%) 164.22 KiB 51.07 KiB ▁▁▁▁▁▆▆▆▆▆▆█
react-start.minimal 99.51 KiB +109 B (+0.11%) 312.69 KiB 86.12 KiB ▁▁▁▁▁▆▆▆▆▆▆█
react-start.full 102.88 KiB +103 B (+0.10%) 322.49 KiB 89.00 KiB ▁▁▁▁▁▆▆▆▆▆▆█
solid-start.minimal 48.49 KiB 0 B (0.00%) 146.03 KiB 42.85 KiB ▁▁▁▁▁██████
solid-start.full 54.05 KiB +87 B (+0.16%) 162.18 KiB 47.68 KiB ▁▁▁▁▁▆▆▆▆▆▆█

Trend sparkline is historical gzip bytes ending with this PR measurement; lower is better.

@jong-kyung jong-kyung force-pushed the fix/heda-content-utils branch from 463260b to c3af3f0 Compare March 4, 2026 12:04
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
packages/vue-router/src/headContentUtils.tsx (1)

89-96: ⚠️ Potential issue | 🟡 Minor

Normalize rel before dedupe matching (still needed).

Line 89 uses a case-sensitive pre-check with raw link.rel, so Canonical / canonical can skip dedupe and allow duplicate canonical tags.

Proposed fix
-          if (link.rel && relsToDedupe.has(link.rel)) {
-            const rel = link.rel?.toLowerCase()
-            if (rel && relsToDedupe.has(rel)) {
-              if (linksByRel.has(rel)) {
-                continue
-              }
-              linksByRel.add(rel)
-            }
-          }
+          const rel =
+            typeof link.rel === 'string' ? link.rel.trim().toLowerCase() : undefined
+          if (rel && relsToDedupe.has(rel)) {
+            if (linksByRel.has(rel)) {
+              continue
+            }
+            linksByRel.add(rel)
+          }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/vue-router/src/headContentUtils.tsx` around lines 89 - 96, The
dedupe pre-check uses the raw case/whitespace-sensitive link.rel which lets
variants like "Canonical" or " canonical " bypass dedupe; normalize link.rel
(trim and toLowerCase) before any membership checks so both the initial if
(relsToDedupe.has(...)) and the subsequent rel variable use the same normalized
value, then use that normalized rel for checks and for adding to
linksByRel/relsToDedupe; update the block around linksByRel, relsToDedupe, and
link.rel to compute a single normalizedRel and use it for all comparisons and
adds.
packages/react-router/src/headContentUtils.tsx (1)

104-111: ⚠️ Potential issue | 🟡 Minor

Normalize rel first; raw pre-check can miss canonical dedupe.

Line 104 performs relsToDedupe.has(link.rel) before normalization, so non-lowercase/trimmed canonical values can bypass dedupe and render duplicates.

Proposed fix
-          if (link.rel && relsToDedupe.has(link.rel)) {
-            const rel = link.rel?.toLowerCase()
-            if (rel && relsToDedupe.has(rel)) {
-              if (linksByRel.has(rel)) {
-                continue
-              }
-              linksByRel.add(rel)
-            }
-          }
+          const rel =
+            typeof link.rel === 'string' ? link.rel.trim().toLowerCase() : undefined
+          if (rel && relsToDedupe.has(rel)) {
+            if (linksByRel.has(rel)) {
+              continue
+            }
+            linksByRel.add(rel)
+          }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router/src/headContentUtils.tsx` around lines 104 - 111, The
dedupe check uses the raw link.rel before normalization which lets
non-lowercase/whitespace variants slip through; in the link-processing logic
(headContentUtils.tsx) normalize link.rel first (e.g., const rel =
link.rel?.toLowerCase().trim()) and then use rel for all checks against
relsToDedupe and linksByRel and for adding to linksByRel, ensuring canonicalized
values are consistently tested and stored to correctly dedupe links.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/solid-router/src/headContentUtils.tsx`:
- Around line 105-112: Normalize link.rel before using it for the dedupe gate:
compute a normalizedRel = link.rel?.trim().toLowerCase() (or similar) at the
start of the block and use normalizedRel for relsToDedupe.has checks and for
adding/checking linksByRel instead of using the raw link.rel; update the
conditional that currently does relsToDedupe.has(link.rel) and subsequent checks
to use normalizedRel so values like "Canonical" or " canonical " are
deduplicated correctly.

---

Duplicate comments:
In `@packages/react-router/src/headContentUtils.tsx`:
- Around line 104-111: The dedupe check uses the raw link.rel before
normalization which lets non-lowercase/whitespace variants slip through; in the
link-processing logic (headContentUtils.tsx) normalize link.rel first (e.g.,
const rel = link.rel?.toLowerCase().trim()) and then use rel for all checks
against relsToDedupe and linksByRel and for adding to linksByRel, ensuring
canonicalized values are consistently tested and stored to correctly dedupe
links.

In `@packages/vue-router/src/headContentUtils.tsx`:
- Around line 89-96: The dedupe pre-check uses the raw case/whitespace-sensitive
link.rel which lets variants like "Canonical" or " canonical " bypass dedupe;
normalize link.rel (trim and toLowerCase) before any membership checks so both
the initial if (relsToDedupe.has(...)) and the subsequent rel variable use the
same normalized value, then use that normalized rel for checks and for adding to
linksByRel/relsToDedupe; update the block around linksByRel, relsToDedupe, and
link.rel to compute a single normalizedRel and use it for all comparisons and
adds.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 98830b12-786d-404d-bb19-58eae388eb77

📥 Commits

Reviewing files that changed from the base of the PR and between 7e91c99 and 463260b.

📒 Files selected for processing (3)
  • packages/react-router/src/headContentUtils.tsx
  • packages/solid-router/src/headContentUtils.tsx
  • packages/vue-router/src/headContentUtils.tsx

Comment on lines +105 to +112
if (link.rel && relsToDedupe.has(link.rel)) {
const rel = link.rel?.toLowerCase()
if (rel && relsToDedupe.has(rel)) {
if (linksByRel.has(rel)) {
continue
}
linksByRel.add(rel)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Normalize rel before the dedupe gate.

Line 105 checks relsToDedupe.has(link.rel) before normalization. Values like Canonical or canonical bypass dedupe and can produce duplicate canonicals.

Proposed fix
-          if (link.rel && relsToDedupe.has(link.rel)) {
-            const rel = link.rel?.toLowerCase()
-            if (rel && relsToDedupe.has(rel)) {
-              if (linksByRel.has(rel)) {
-                continue
-              }
-              linksByRel.add(rel)
-            }
-          }
+          const rel =
+            typeof link.rel === 'string' ? link.rel.trim().toLowerCase() : undefined
+          if (rel && relsToDedupe.has(rel)) {
+            if (linksByRel.has(rel)) {
+              continue
+            }
+            linksByRel.add(rel)
+          }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (link.rel && relsToDedupe.has(link.rel)) {
const rel = link.rel?.toLowerCase()
if (rel && relsToDedupe.has(rel)) {
if (linksByRel.has(rel)) {
continue
}
linksByRel.add(rel)
}
const rel =
typeof link.rel === 'string' ? link.rel.trim().toLowerCase() : undefined
if (rel && relsToDedupe.has(rel)) {
if (linksByRel.has(rel)) {
continue
}
linksByRel.add(rel)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/solid-router/src/headContentUtils.tsx` around lines 105 - 112,
Normalize link.rel before using it for the dedupe gate: compute a normalizedRel
= link.rel?.trim().toLowerCase() (or similar) at the start of the block and use
normalizedRel for relsToDedupe.has checks and for adding/checking linksByRel
instead of using the raw link.rel; update the conditional that currently does
relsToDedupe.has(link.rel) and subsequent checks to use normalizedRel so values
like "Canonical" or " canonical " are deduplicated correctly.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
packages/solid-router/src/headContentUtils.tsx (1)

105-112: ⚠️ Potential issue | 🟡 Minor

Normalize rel before dedupe lookup.

toLowerCase() alone still lets values like ' canonical ' bypass dedupe, so duplicate canonicals can slip through.

Proposed fix
-          if (link.rel) {
-            const rel = link.rel.toLowerCase()
-            if (relsToDedupe.has(rel)) {
-              if (linksByRel.has(rel)) {
-                continue
-              }
-              linksByRel.add(rel)
-            }
-          }
+          const rel =
+            typeof link.rel === 'string' ? link.rel.trim().toLowerCase() : undefined
+          if (rel && relsToDedupe.has(rel)) {
+            if (linksByRel.has(rel)) {
+              continue
+            }
+            linksByRel.add(rel)
+          }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/solid-router/src/headContentUtils.tsx` around lines 105 - 112, The
dedupe logic uses link.rel.toLowerCase() which doesn't remove surrounding
whitespace so values like " canonical " bypass relsToDedupe; update the
normalization in headContentUtils.tsx to trim and lowercase the rel (e.g., use
link.rel.trim().toLowerCase()) before checking relsToDedupe and before using
linksByRel, and ensure you still guard for a non-empty rel value when computing
the normalized rel.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/solid-router/src/headContentUtils.tsx`:
- Around line 105-112: The dedupe logic uses link.rel.toLowerCase() which
doesn't remove surrounding whitespace so values like " canonical " bypass
relsToDedupe; update the normalization in headContentUtils.tsx to trim and
lowercase the rel (e.g., use link.rel.trim().toLowerCase()) before checking
relsToDedupe and before using linksByRel, and ensure you still guard for a
non-empty rel value when computing the normalized rel.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 34c57109-1654-4606-a85a-a3db0f717060

📥 Commits

Reviewing files that changed from the base of the PR and between 463260b and c3af3f0.

📒 Files selected for processing (3)
  • packages/react-router/src/headContentUtils.tsx
  • packages/solid-router/src/headContentUtils.tsx
  • packages/vue-router/src/headContentUtils.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/vue-router/src/headContentUtils.tsx
  • packages/react-router/src/headContentUtils.tsx

@codspeed-hq
Copy link

codspeed-hq bot commented Mar 4, 2026

Merging this PR will not alter performance

✅ 6 untouched benchmarks


Comparing jong-kyung:fix/heda-content-utils (3f360b4) with main (4ea2a62)

Open in CodSpeed

@jong-kyung
Copy link
Contributor Author

I'm leaving this PR open for review, but please note that the E2E tests for vue-start and solid-start are currently failing due to framework-specific behavior in the test environments.

Here is what I found after digging deeper:

  1. vue-start: The test fails because it detects 2 canonical links. I verified via pnpm dev that this is a real issue—the entire <head> block is being duplicated during SSR/Hydration. I've opened [Bug] Duplicate elements generated inside <head> tag in vue-start #6764 to track this framework bug.
  2. solid-start: The test fails because it detects 0 canonical links. However, when I run pnpm dev locally, it correctly outputs exactly 1 canonical link. This seems like a hydration timing or setup issue specific to the Playwright test environment, rather than a flaw in the logic.

@schiller-manuel, could you please review my core deduplication logic? If the logic looks good, how should we handle the E2E tests? Any insights on why the solid-start tests might be failing to catch the injected tags would be greatly appreciated!

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

head: child route links cannot override parent route links (e.g. rel="canonical")

1 participant