diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts
index 2c101bb9da4..6d952ff0ca1 100644
--- a/e2e/react-start/basic/src/routeTree.gen.ts
+++ b/e2e/react-start/basic/src/routeTree.gen.ts
@@ -24,6 +24,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as SpecialCharsRouteRouteImport } from './routes/specialChars/route'
import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route'
import { Route as NotFoundRouteRouteImport } from './routes/not-found/route'
+import { Route as CanonicalRouteRouteImport } from './routes/canonical/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as UsersIndexRouteImport } from './routes/users.index'
import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index'
@@ -54,6 +55,7 @@ import { Route as MultiCookieRedirectTargetRouteImport } from './routes/multi-co
import { Route as ApiUsersRouteImport } from './routes/api.users'
import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2'
import { Route as SpecialCharsMalformedRouteRouteImport } from './routes/specialChars/malformed/route'
+import { Route as CanonicalDeepRouteRouteImport } from './routes/canonical/deep/route'
import { Route as NotFoundParentBoundaryRouteRouteImport } from './routes/not-found/parent-boundary/route'
import { Route as NotFoundDeepRouteRouteImport } from './routes/not-found/deep/route'
import { Route as RedirectTargetIndexRouteImport } from './routes/redirect/$target/index'
@@ -152,6 +154,11 @@ const NotFoundRouteRoute = NotFoundRouteRouteImport.update({
path: '/not-found',
getParentRoute: () => rootRouteImport,
} as any)
+const CanonicalRouteRoute = CanonicalRouteRouteImport.update({
+ id: '/canonical',
+ path: '/canonical',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -307,6 +314,11 @@ const SpecialCharsMalformedRouteRoute =
path: '/malformed',
getParentRoute: () => SpecialCharsRouteRoute,
} as any)
+const CanonicalDeepRouteRoute = CanonicalDeepRouteRouteImport.update({
+ id: '/deep',
+ path: '/deep',
+ getParentRoute: () => CanonicalRouteRoute,
+} as any)
const NotFoundParentBoundaryRouteRoute =
NotFoundParentBoundaryRouteRouteImport.update({
id: '/parent-boundary',
@@ -435,6 +447,7 @@ const NotFoundDeepBCDRoute = NotFoundDeepBCDRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
@@ -449,6 +462,7 @@ export interface FileRoutesByFullPath {
'/stream': typeof StreamRoute
'/type-only-reexport': typeof TypeOnlyReexportRoute
'/users': typeof UsersRouteWithChildren
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/not-found/deep': typeof NotFoundDeepRouteRouteWithChildren
'/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
@@ -503,6 +517,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/async-scripts': typeof AsyncScriptsRoute
'/client-only': typeof ClientOnlyRoute
@@ -512,6 +527,7 @@ export interface FileRoutesByTo {
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
'/type-only-reexport': typeof TypeOnlyReexportRoute
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
@@ -563,6 +579,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
@@ -578,6 +595,7 @@ export interface FileRoutesById {
'/stream': typeof StreamRoute
'/type-only-reexport': typeof TypeOnlyReexportRoute
'/users': typeof UsersRouteWithChildren
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/not-found/deep': typeof NotFoundDeepRouteRouteWithChildren
'/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
@@ -635,6 +653,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/canonical'
| '/not-found'
| '/search-params'
| '/specialChars'
@@ -649,6 +668,7 @@ export interface FileRouteTypes {
| '/stream'
| '/type-only-reexport'
| '/users'
+ | '/canonical/deep'
| '/not-found/deep'
| '/not-found/parent-boundary'
| '/specialChars/malformed'
@@ -703,6 +723,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/canonical'
| '/specialChars'
| '/async-scripts'
| '/client-only'
@@ -712,6 +733,7 @@ export interface FileRouteTypes {
| '/scripts'
| '/stream'
| '/type-only-reexport'
+ | '/canonical/deep'
| '/specialChars/malformed'
| '/api/users'
| '/multi-cookie-redirect/target'
@@ -762,6 +784,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
+ | '/canonical'
| '/not-found'
| '/search-params'
| '/specialChars'
@@ -777,6 +800,7 @@ export interface FileRouteTypes {
| '/stream'
| '/type-only-reexport'
| '/users'
+ | '/canonical/deep'
| '/not-found/deep'
| '/not-found/parent-boundary'
| '/specialChars/malformed'
@@ -833,6 +857,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ CanonicalRouteRoute: typeof CanonicalRouteRouteWithChildren
NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren
SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren
SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren
@@ -964,6 +989,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof NotFoundRouteRouteImport
parentRoute: typeof rootRouteImport
}
+ '/canonical': {
+ id: '/canonical'
+ path: '/canonical'
+ fullPath: '/canonical'
+ preLoaderRoute: typeof CanonicalRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/': {
id: '/'
path: '/'
@@ -1174,6 +1206,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SpecialCharsMalformedRouteRouteImport
parentRoute: typeof SpecialCharsRouteRoute
}
+ '/canonical/deep': {
+ id: '/canonical/deep'
+ path: '/deep'
+ fullPath: '/canonical/deep'
+ preLoaderRoute: typeof CanonicalDeepRouteRouteImport
+ parentRoute: typeof CanonicalRouteRoute
+ }
'/not-found/parent-boundary': {
id: '/not-found/parent-boundary'
path: '/parent-boundary'
@@ -1338,6 +1377,17 @@ declare module '@tanstack/react-router' {
}
}
+interface CanonicalRouteRouteChildren {
+ CanonicalDeepRouteRoute: typeof CanonicalDeepRouteRoute
+}
+
+const CanonicalRouteRouteChildren: CanonicalRouteRouteChildren = {
+ CanonicalDeepRouteRoute: CanonicalDeepRouteRoute,
+}
+
+const CanonicalRouteRouteWithChildren = CanonicalRouteRoute._addFileChildren(
+ CanonicalRouteRouteChildren,
+)
interface NotFoundDeepBCRouteRouteChildren {
NotFoundDeepBCDRoute: typeof NotFoundDeepBCDRoute
}
@@ -1589,6 +1639,7 @@ const FooBarQuxHereRouteWithChildren = FooBarQuxHereRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ CanonicalRouteRoute: CanonicalRouteRouteWithChildren,
NotFoundRouteRoute: NotFoundRouteRouteWithChildren,
SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren,
SpecialCharsRouteRoute: SpecialCharsRouteRouteWithChildren,
diff --git a/e2e/react-start/basic/src/routes/canonical/deep/route.tsx b/e2e/react-start/basic/src/routes/canonical/deep/route.tsx
new file mode 100644
index 00000000000..eeeb074f14d
--- /dev/null
+++ b/e2e/react-start/basic/src/routes/canonical/deep/route.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/canonical/deep')({
+ head: () => ({
+ links: [{ rel: 'canonical', href: 'https://example.com/canonical/deep' }],
+ }),
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return
Hello "/canonical/deep"!
+}
diff --git a/e2e/react-start/basic/src/routes/canonical/route.tsx b/e2e/react-start/basic/src/routes/canonical/route.tsx
new file mode 100644
index 00000000000..653ac880b35
--- /dev/null
+++ b/e2e/react-start/basic/src/routes/canonical/route.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/canonical')({
+ head: () => ({
+ links: [{ rel: 'canonical', href: 'https://example.com/canonical' }],
+ }),
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return Hello "/canonical"!
+}
diff --git a/e2e/react-start/basic/tests/canonical.spec.ts b/e2e/react-start/basic/tests/canonical.spec.ts
new file mode 100644
index 00000000000..ff35bb0832d
--- /dev/null
+++ b/e2e/react-start/basic/tests/canonical.spec.ts
@@ -0,0 +1,18 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+
+test('Deduplicates child canonical links over parent', async ({ page }) => {
+ await page.goto('/canonical/deep')
+ await page.waitForURL('/canonical/deep')
+
+ 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(
+ 'href',
+ 'https://example.com/canonical/deep',
+ )
+})
diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts
index c8cecb85b1e..90f6a896e18 100644
--- a/e2e/solid-start/basic/src/routeTree.gen.ts
+++ b/e2e/solid-start/basic/src/routeTree.gen.ts
@@ -21,6 +21,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as SpecialCharsRouteRouteImport } from './routes/specialChars/route'
import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route'
import { Route as NotFoundRouteRouteImport } from './routes/not-found/route'
+import { Route as CanonicalRouteRouteImport } from './routes/canonical/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as UsersIndexRouteImport } from './routes/users.index'
import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index'
@@ -51,6 +52,7 @@ import { Route as MultiCookieRedirectTargetRouteImport } from './routes/multi-co
import { Route as ApiUsersRouteImport } from './routes/api/users'
import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2'
import { Route as SpecialCharsMalformedRouteRouteImport } from './routes/specialChars/malformed/route'
+import { Route as CanonicalDeepRouteRouteImport } from './routes/canonical/deep/route'
import { Route as NotFoundParentBoundaryRouteRouteImport } from './routes/not-found/parent-boundary/route'
import { Route as RedirectTargetIndexRouteImport } from './routes/redirect/$target/index'
import { Route as TransitionTypingCreateResourceRouteImport } from './routes/transition/typing/create-resource'
@@ -128,6 +130,11 @@ const NotFoundRouteRoute = NotFoundRouteRouteImport.update({
path: '/not-found',
getParentRoute: () => rootRouteImport,
} as any)
+const CanonicalRouteRoute = CanonicalRouteRouteImport.update({
+ id: '/canonical',
+ path: '/canonical',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -283,6 +290,11 @@ const SpecialCharsMalformedRouteRoute =
path: '/malformed',
getParentRoute: () => SpecialCharsRouteRoute,
} as any)
+const CanonicalDeepRouteRoute = CanonicalDeepRouteRouteImport.update({
+ id: '/deep',
+ path: '/deep',
+ getParentRoute: () => CanonicalRouteRoute,
+} as any)
const NotFoundParentBoundaryRouteRoute =
NotFoundParentBoundaryRouteRouteImport.update({
id: '/parent-boundary',
@@ -382,6 +394,7 @@ const RedirectTargetServerFnViaBeforeLoadRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
@@ -393,6 +406,7 @@ export interface FileRoutesByFullPath {
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
'/users': typeof UsersRouteWithChildren
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
@@ -441,12 +455,14 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/deferred': typeof DeferredRoute
'/inline-scripts': typeof InlineScriptsRoute
'/links': typeof LinksRoute
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
@@ -495,6 +511,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
@@ -507,6 +524,7 @@ export interface FileRoutesById {
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
'/users': typeof UsersRouteWithChildren
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren
@@ -558,6 +576,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/canonical'
| '/not-found'
| '/search-params'
| '/specialChars'
@@ -569,6 +588,7 @@ export interface FileRouteTypes {
| '/scripts'
| '/stream'
| '/users'
+ | '/canonical/deep'
| '/not-found/parent-boundary'
| '/specialChars/malformed'
| '/api/users'
@@ -617,12 +637,14 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/canonical'
| '/specialChars'
| '/deferred'
| '/inline-scripts'
| '/links'
| '/scripts'
| '/stream'
+ | '/canonical/deep'
| '/not-found/parent-boundary'
| '/specialChars/malformed'
| '/api/users'
@@ -670,6 +692,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
+ | '/canonical'
| '/not-found'
| '/search-params'
| '/specialChars'
@@ -682,6 +705,7 @@ export interface FileRouteTypes {
| '/scripts'
| '/stream'
| '/users'
+ | '/canonical/deep'
| '/not-found/parent-boundary'
| '/specialChars/malformed'
| '/_layout/_layout-2'
@@ -732,6 +756,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ CanonicalRouteRoute: typeof CanonicalRouteRouteWithChildren
NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren
SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren
SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren
@@ -840,6 +865,13 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof NotFoundRouteRouteImport
parentRoute: typeof rootRouteImport
}
+ '/canonical': {
+ id: '/canonical'
+ path: '/canonical'
+ fullPath: '/canonical'
+ preLoaderRoute: typeof CanonicalRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/': {
id: '/'
path: '/'
@@ -1050,6 +1082,13 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof SpecialCharsMalformedRouteRouteImport
parentRoute: typeof SpecialCharsRouteRoute
}
+ '/canonical/deep': {
+ id: '/canonical/deep'
+ path: '/deep'
+ fullPath: '/canonical/deep'
+ preLoaderRoute: typeof CanonicalDeepRouteRouteImport
+ parentRoute: typeof CanonicalRouteRoute
+ }
'/not-found/parent-boundary': {
id: '/not-found/parent-boundary'
path: '/parent-boundary'
@@ -1172,6 +1211,17 @@ declare module '@tanstack/solid-router' {
}
}
+interface CanonicalRouteRouteChildren {
+ CanonicalDeepRouteRoute: typeof CanonicalDeepRouteRoute
+}
+
+const CanonicalRouteRouteChildren: CanonicalRouteRouteChildren = {
+ CanonicalDeepRouteRoute: CanonicalDeepRouteRoute,
+}
+
+const CanonicalRouteRouteWithChildren = CanonicalRouteRoute._addFileChildren(
+ CanonicalRouteRouteChildren,
+)
interface NotFoundParentBoundaryRouteRouteChildren {
NotFoundParentBoundaryViaBeforeLoadRoute: typeof NotFoundParentBoundaryViaBeforeLoadRoute
}
@@ -1372,6 +1422,7 @@ const RedirectTargetRouteWithChildren = RedirectTargetRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ CanonicalRouteRoute: CanonicalRouteRouteWithChildren,
NotFoundRouteRoute: NotFoundRouteRouteWithChildren,
SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren,
SpecialCharsRouteRoute: SpecialCharsRouteRouteWithChildren,
diff --git a/e2e/solid-start/basic/src/routes/canonical/deep/route.tsx b/e2e/solid-start/basic/src/routes/canonical/deep/route.tsx
new file mode 100644
index 00000000000..bdeea7d4055
--- /dev/null
+++ b/e2e/solid-start/basic/src/routes/canonical/deep/route.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/canonical/deep')({
+ head: () => ({
+ links: [{ rel: 'canonical', href: 'https://example.com/canonical/deep' }],
+ }),
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return Hello "/canonical/deep"!
+}
diff --git a/e2e/solid-start/basic/src/routes/canonical/route.tsx b/e2e/solid-start/basic/src/routes/canonical/route.tsx
new file mode 100644
index 00000000000..c132eb1160d
--- /dev/null
+++ b/e2e/solid-start/basic/src/routes/canonical/route.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/canonical')({
+ head: () => ({
+ links: [{ rel: 'canonical', href: 'https://example.com/canonical' }],
+ }),
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return Hello "/canonical"!
+}
diff --git a/e2e/solid-start/basic/tests/canonical.spec.ts b/e2e/solid-start/basic/tests/canonical.spec.ts
new file mode 100644
index 00000000000..ff35bb0832d
--- /dev/null
+++ b/e2e/solid-start/basic/tests/canonical.spec.ts
@@ -0,0 +1,18 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+
+test('Deduplicates child canonical links over parent', async ({ page }) => {
+ await page.goto('/canonical/deep')
+ await page.waitForURL('/canonical/deep')
+
+ 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(
+ 'href',
+ 'https://example.com/canonical/deep',
+ )
+})
diff --git a/e2e/vue-start/basic/src/routeTree.gen.ts b/e2e/vue-start/basic/src/routeTree.gen.ts
index 016a292835b..3672360367a 100644
--- a/e2e/vue-start/basic/src/routeTree.gen.ts
+++ b/e2e/vue-start/basic/src/routeTree.gen.ts
@@ -21,6 +21,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as SpecialCharsRouteRouteImport } from './routes/specialChars/route'
import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route'
import { Route as NotFoundRouteRouteImport } from './routes/not-found/route'
+import { Route as CanonicalRouteRouteImport } from './routes/canonical/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as UsersIndexRouteImport } from './routes/users.index'
import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index'
@@ -51,6 +52,7 @@ import { Route as MultiCookieRedirectTargetRouteImport } from './routes/multi-co
import { Route as ApiUsersRouteImport } from './routes/api/users'
import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2'
import { Route as SpecialCharsMalformedRouteRouteImport } from './routes/specialChars/malformed/route'
+import { Route as CanonicalDeepRouteRouteImport } from './routes/canonical/deep/route'
import { Route as NotFoundParentBoundaryRouteRouteImport } from './routes/not-found/parent-boundary/route'
import { Route as RedirectTargetIndexRouteImport } from './routes/redirect/$target/index'
import { Route as SpecialCharsMalformedSearchRouteImport } from './routes/specialChars/malformed/search'
@@ -126,6 +128,11 @@ const NotFoundRouteRoute = NotFoundRouteRouteImport.update({
path: '/not-found',
getParentRoute: () => rootRouteImport,
} as any)
+const CanonicalRouteRoute = CanonicalRouteRouteImport.update({
+ id: '/canonical',
+ path: '/canonical',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -281,6 +288,11 @@ const SpecialCharsMalformedRouteRoute =
path: '/malformed',
getParentRoute: () => SpecialCharsRouteRoute,
} as any)
+const CanonicalDeepRouteRoute = CanonicalDeepRouteRouteImport.update({
+ id: '/deep',
+ path: '/deep',
+ getParentRoute: () => CanonicalRouteRoute,
+} as any)
const NotFoundParentBoundaryRouteRoute =
NotFoundParentBoundaryRouteRouteImport.update({
id: '/parent-boundary',
@@ -368,6 +380,7 @@ const RedirectTargetServerFnViaBeforeLoadRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
@@ -379,6 +392,7 @@ export interface FileRoutesByFullPath {
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
'/users': typeof UsersRouteWithChildren
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
@@ -425,12 +439,14 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/deferred': typeof DeferredRoute
'/inline-scripts': typeof InlineScriptsRoute
'/links': typeof LinksRoute
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
@@ -477,6 +493,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/canonical': typeof CanonicalRouteRouteWithChildren
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
@@ -489,6 +506,7 @@ export interface FileRoutesById {
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
'/users': typeof UsersRouteWithChildren
+ '/canonical/deep': typeof CanonicalDeepRouteRoute
'/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren
@@ -538,6 +556,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/canonical'
| '/not-found'
| '/search-params'
| '/specialChars'
@@ -549,6 +568,7 @@ export interface FileRouteTypes {
| '/scripts'
| '/stream'
| '/users'
+ | '/canonical/deep'
| '/not-found/parent-boundary'
| '/specialChars/malformed'
| '/api/users'
@@ -595,12 +615,14 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/canonical'
| '/specialChars'
| '/deferred'
| '/inline-scripts'
| '/links'
| '/scripts'
| '/stream'
+ | '/canonical/deep'
| '/not-found/parent-boundary'
| '/specialChars/malformed'
| '/api/users'
@@ -646,6 +668,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
+ | '/canonical'
| '/not-found'
| '/search-params'
| '/specialChars'
@@ -658,6 +681,7 @@ export interface FileRouteTypes {
| '/scripts'
| '/stream'
| '/users'
+ | '/canonical/deep'
| '/not-found/parent-boundary'
| '/specialChars/malformed'
| '/_layout/_layout-2'
@@ -706,6 +730,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ CanonicalRouteRoute: typeof CanonicalRouteRouteWithChildren
NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren
SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren
SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren
@@ -812,6 +837,13 @@ declare module '@tanstack/vue-router' {
preLoaderRoute: typeof NotFoundRouteRouteImport
parentRoute: typeof rootRouteImport
}
+ '/canonical': {
+ id: '/canonical'
+ path: '/canonical'
+ fullPath: '/canonical'
+ preLoaderRoute: typeof CanonicalRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/': {
id: '/'
path: '/'
@@ -1022,6 +1054,13 @@ declare module '@tanstack/vue-router' {
preLoaderRoute: typeof SpecialCharsMalformedRouteRouteImport
parentRoute: typeof SpecialCharsRouteRoute
}
+ '/canonical/deep': {
+ id: '/canonical/deep'
+ path: '/deep'
+ fullPath: '/canonical/deep'
+ preLoaderRoute: typeof CanonicalDeepRouteRouteImport
+ parentRoute: typeof CanonicalRouteRoute
+ }
'/not-found/parent-boundary': {
id: '/not-found/parent-boundary'
path: '/parent-boundary'
@@ -1130,6 +1169,17 @@ declare module '@tanstack/vue-router' {
}
}
+interface CanonicalRouteRouteChildren {
+ CanonicalDeepRouteRoute: typeof CanonicalDeepRouteRoute
+}
+
+const CanonicalRouteRouteChildren: CanonicalRouteRouteChildren = {
+ CanonicalDeepRouteRoute: CanonicalDeepRouteRoute,
+}
+
+const CanonicalRouteRouteWithChildren = CanonicalRouteRoute._addFileChildren(
+ CanonicalRouteRouteChildren,
+)
interface NotFoundParentBoundaryRouteRouteChildren {
NotFoundParentBoundaryViaBeforeLoadRoute: typeof NotFoundParentBoundaryViaBeforeLoadRoute
}
@@ -1330,6 +1380,7 @@ const RedirectTargetRouteWithChildren = RedirectTargetRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ CanonicalRouteRoute: CanonicalRouteRouteWithChildren,
NotFoundRouteRoute: NotFoundRouteRouteWithChildren,
SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren,
SpecialCharsRouteRoute: SpecialCharsRouteRouteWithChildren,
diff --git a/e2e/vue-start/basic/src/routes/canonical/deep/route.tsx b/e2e/vue-start/basic/src/routes/canonical/deep/route.tsx
new file mode 100644
index 00000000000..853e572966c
--- /dev/null
+++ b/e2e/vue-start/basic/src/routes/canonical/deep/route.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/vue-router'
+
+export const Route = createFileRoute('/canonical/deep')({
+ head: () => ({
+ links: [{ rel: 'canonical', href: 'https://example.com/canonical/deep' }],
+ }),
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return Hello "/canonical/deep"!
+}
diff --git a/e2e/vue-start/basic/src/routes/canonical/route.tsx b/e2e/vue-start/basic/src/routes/canonical/route.tsx
new file mode 100644
index 00000000000..874415f9604
--- /dev/null
+++ b/e2e/vue-start/basic/src/routes/canonical/route.tsx
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/vue-router'
+
+export const Route = createFileRoute('/canonical')({
+ head: () => ({
+ links: [{ rel: 'canonical', href: 'https://example.com/canonical' }],
+ }),
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return Hello "/canonical"!
+}
diff --git a/e2e/vue-start/basic/tests/canonical.spec.ts b/e2e/vue-start/basic/tests/canonical.spec.ts
new file mode 100644
index 00000000000..6c240988e44
--- /dev/null
+++ b/e2e/vue-start/basic/tests/canonical.spec.ts
@@ -0,0 +1,17 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+
+test('Deduplicates child canonical links over parent', async ({ page }) => {
+ await page.goto('/canonical/deep')
+ await page.waitForURL('/canonical/deep')
+
+ 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(
+ 'href',
+ 'https://example.com/canonical/deep',
+ )
+})
diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx
index 1345eebb22d..8d26c876c9f 100644
--- a/packages/react-router/src/headContentUtils.tsx
+++ b/packages/react-router/src/headContentUtils.tsx
@@ -90,17 +90,39 @@ export const useTags = () => {
const links = useRouterState({
select: (state) => {
- const constructed = state.matches
- .map((match) => match.links!)
- .filter(Boolean)
- .flat(1)
- .map((link) => ({
- tag: 'link',
- attrs: {
- ...link,
- nonce,
- },
- })) satisfies Array
+ const constructedLinks: Array = []
+ const relsToDedupe = new Set(['canonical'])
+ const linksByRel = new Set()
+
+ for (let i = state.matches.length - 1; i >= 0; i--) {
+ const match = state.matches[i]!
+ const matchLinks = match.links
+ if (!matchLinks) continue
+
+ for (let j = matchLinks.length - 1; j >= 0; j--) {
+ const link = matchLinks[j]!
+ if (link.rel) {
+ const rel = link.rel.toLowerCase()
+ if (relsToDedupe.has(rel)) {
+ if (linksByRel.has(rel)) {
+ continue
+ }
+ linksByRel.add(rel)
+ }
+ }
+
+ constructedLinks.push({
+ tag: 'link',
+ attrs: {
+ ...link,
+ nonce,
+ },
+ })
+ }
+ }
+
+ constructedLinks.reverse()
+ const constructed = constructedLinks satisfies Array
const manifest = router.ssr?.manifest
diff --git a/packages/solid-router/src/headContentUtils.tsx b/packages/solid-router/src/headContentUtils.tsx
index 1aaf927afae..5abba4b3bfb 100644
--- a/packages/solid-router/src/headContentUtils.tsx
+++ b/packages/solid-router/src/headContentUtils.tsx
@@ -91,17 +91,39 @@ export const useTags = () => {
const links = useRouterState({
select: (state) => {
- const constructed = state.matches
- .map((match) => match.links!)
- .filter(Boolean)
- .flat(1)
- .map((link) => ({
- tag: 'link',
- attrs: {
- ...link,
- nonce,
- },
- })) satisfies Array
+ const constructedLinks: Array = []
+ const relsToDedupe = new Set(['canonical'])
+ const linksByRel = new Set()
+
+ for (let i = state.matches.length - 1; i >= 0; i--) {
+ const match = state.matches[i]!
+ const matchLinks = match.links
+ if (!matchLinks) continue
+
+ for (let j = matchLinks.length - 1; j >= 0; j--) {
+ const link = matchLinks[j]!
+ if (link.rel) {
+ const rel = link.rel.toLowerCase()
+ if (relsToDedupe.has(rel)) {
+ if (linksByRel.has(rel)) {
+ continue
+ }
+ linksByRel.add(rel)
+ }
+ }
+
+ constructedLinks.push({
+ tag: 'link',
+ attrs: {
+ ...link,
+ nonce,
+ },
+ })
+ }
+ }
+
+ constructedLinks.reverse()
+ const constructed = constructedLinks satisfies Array
const manifest = router.ssr?.manifest
diff --git a/packages/vue-router/src/headContentUtils.tsx b/packages/vue-router/src/headContentUtils.tsx
index ae0fadbff1a..bbbaaa9bbf3 100644
--- a/packages/vue-router/src/headContentUtils.tsx
+++ b/packages/vue-router/src/headContentUtils.tsx
@@ -74,17 +74,40 @@ export const useTags = () => {
})
const links = useRouterState({
- select: (state) =>
- state.matches
- .map((match) => match.links!)
- .filter(Boolean)
- .flat(1)
- .map((link) => ({
- tag: 'link',
- attrs: {
- ...link,
- },
- })) as Array,
+ select: (state) => {
+ const constructedLinks: Array = []
+ const relsToDedupe = new Set(['canonical'])
+ const linksByRel = new Set()
+
+ for (let i = state.matches.length - 1; i >= 0; i--) {
+ const match = state.matches[i]!
+ const matchLinks = match.links
+ if (!matchLinks) continue
+
+ for (let j = matchLinks.length - 1; j >= 0; j--) {
+ const link = matchLinks[j]!
+ if (link.rel) {
+ const rel = link.rel.toLowerCase()
+ if (relsToDedupe.has(rel)) {
+ if (linksByRel.has(rel)) {
+ continue
+ }
+ linksByRel.add(rel)
+ }
+ }
+
+ constructedLinks.push({
+ tag: 'link',
+ attrs: {
+ ...link,
+ },
+ })
+ }
+ }
+
+ constructedLinks.reverse()
+ return constructedLinks satisfies Array
+ },
})
const preloadMeta = useRouterState({