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 2 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 2 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 in example apps (React, Solid, Vue).
    • Head generation now deduplicates canonical link tags so only one rel="canonical" appears.
  • Tests

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

@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

View your CI Pipeline Execution ↗ for commit 7e91c99

Command Status Duration Result
nx run tanstack-router-e2e-bundle-size:build --... ✅ Succeeded 1m 21s View ↗

☁️ Nx Cloud last updated this comment at 2026-02-25 12:30:40 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/router/@tanstack/arktype-adapter@6761

@tanstack/eslint-plugin-router

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

@tanstack/history

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

@tanstack/nitro-v2-vite-plugin

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

@tanstack/react-router

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

@tanstack/react-router-devtools

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

@tanstack/react-router-ssr-query

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

@tanstack/react-start

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

@tanstack/react-start-client

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

@tanstack/react-start-server

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

@tanstack/router-cli

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

@tanstack/router-core

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

@tanstack/router-devtools

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

@tanstack/router-devtools-core

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

@tanstack/router-generator

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

@tanstack/router-plugin

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

@tanstack/router-ssr-query-core

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

@tanstack/router-utils

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

@tanstack/router-vite-plugin

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

@tanstack/solid-router

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

@tanstack/solid-router-devtools

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

@tanstack/solid-router-ssr-query

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

@tanstack/solid-start

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

@tanstack/solid-start-client

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

@tanstack/solid-start-server

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

@tanstack/start-client-core

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

@tanstack/start-fn-stubs

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

@tanstack/start-plugin-core

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

@tanstack/start-server-core

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

@tanstack/start-static-server-functions

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

@tanstack/start-storage-context

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

@tanstack/valibot-adapter

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

@tanstack/virtual-file-routes

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

@tanstack/vue-router

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

@tanstack/vue-router-devtools

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

@tanstack/vue-router-ssr-query

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

@tanstack/vue-start

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

@tanstack/vue-start-client

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

@tanstack/vue-start-server

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

@tanstack/zod-adapter

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

commit: 7e91c99

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 25, 2026

📝 Walkthrough

Walkthrough

Adds canonical file routes (/canonical and /canonical/deep) and E2E tests across React, Solid, and Vue starters. Refactors head tag construction in router packages to iterate matches deepest-first, deduplicate link tags by rel (so child route canonical links override parents), and preserve deterministic order with nonce injection.

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 route tree entries, children types, and public exports.
React — E2E
e2e/react-start/basic/tests/canonical.spec.ts
Adds test that navigates to /canonical/deep, asserts deduplication to one <link rel="canonical">, and verifies 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 routes with canonical head links; updates generated route tree 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 and updates generated route tree, types, and exports to include child wiring.
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: iterate matches from deepest to root, deduplicate by rel (canonical prioritized by child), attach nonce to tags, then reverse to final deterministic order; replaces previous flatMap/flatten approach.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • birkskyum
  • nlynzaad

Poem

🐰 I hopped through routes both deep and grand,

Trimmed extra links with a careful hand,
Child canonical now takes the lead,
Parent duplicates gently recede.
A joyful rabbit twitches its nose — hooray!

🚥 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 clearly and concisely summarizes the main change: fixing deduplication of specific link tags (canonical) to allow child route overrides.
Linked Issues check ✅ Passed The PR successfully implements all requirements from issue #6719: selectively deduplicates canonical links [6719], processes matches from child-to-root [6719], uses efficient O(1) lookup [6719], and includes e2e tests verifying child route canonical overrides [6719].
Out of Scope Changes check ✅ Passed All changes are in-scope: route files and tests demonstrate the canonical override feature, headContentUtils in three router packages implement the deduplication logic, and generated routeTree files reflect the new routes.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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 +104 to +109
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 check to prevent missed canonical overrides.

relsToDedupe.has(link.rel) is case-sensitive. If a route sets rel: 'Canonical', it won’t dedupe against canonical, so parent and child canonical links can both render.

Proposed fix
-      const linksByRel: Record<string, true> = {}
+      const linksByRel = new Set<string>()
@@
-          if (link.rel && relsToDedupe.has(link.rel)) {
-            if (linksByRel[link.rel]) {
+          const rel = link.rel?.toLowerCase()
+          if (rel && relsToDedupe.has(rel)) {
+            if (linksByRel.has(rel)) {
               continue
             }
-            linksByRel[link.rel] = true
+            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 - 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.

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.

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