From 30ca5da9e6ac9f35f20dec5844e3793368387581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A2=85=EA=B2=BD?= Date: Wed, 25 Feb 2026 20:09:58 +0900 Subject: [PATCH 1/2] feat: add canonical routes and corresponding tests for deep and base paths --- e2e/react-start/basic/src/routeTree.gen.ts | 52 +++++++++++++++++++ .../basic/src/routes/canonical/deep/route.tsx | 12 +++++ .../basic/src/routes/canonical/route.tsx | 12 +++++ e2e/react-start/basic/tests/canonical.spec.ts | 18 +++++++ e2e/solid-start/basic/src/routeTree.gen.ts | 52 +++++++++++++++++++ .../basic/src/routes/canonical/deep/route.tsx | 12 +++++ .../basic/src/routes/canonical/route.tsx | 12 +++++ e2e/solid-start/basic/tests/canonical.spec.ts | 18 +++++++ e2e/vue-start/basic/src/routeTree.gen.ts | 52 +++++++++++++++++++ .../basic/src/routes/canonical/deep/route.tsx | 12 +++++ .../basic/src/routes/canonical/route.tsx | 12 +++++ e2e/vue-start/basic/tests/canonical.spec.ts | 17 ++++++ 12 files changed, 281 insertions(+) create mode 100644 e2e/react-start/basic/src/routes/canonical/deep/route.tsx create mode 100644 e2e/react-start/basic/src/routes/canonical/route.tsx create mode 100644 e2e/react-start/basic/tests/canonical.spec.ts create mode 100644 e2e/solid-start/basic/src/routes/canonical/deep/route.tsx create mode 100644 e2e/solid-start/basic/src/routes/canonical/route.tsx create mode 100644 e2e/solid-start/basic/tests/canonical.spec.ts create mode 100644 e2e/vue-start/basic/src/routes/canonical/deep/route.tsx create mode 100644 e2e/vue-start/basic/src/routes/canonical/route.tsx create mode 100644 e2e/vue-start/basic/tests/canonical.spec.ts diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index 6efad91f8d7..0160f355d98 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' @@ -53,6 +54,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 RedirectTargetIndexRouteImport } from './routes/redirect/$target/index' import { Route as SpecialCharsMalformedSearchRouteImport } from './routes/specialChars/malformed/search' import { Route as SpecialCharsMalformedParamRouteImport } from './routes/specialChars/malformed/$param' @@ -143,6 +145,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: '/', @@ -292,6 +299,11 @@ const SpecialCharsMalformedRouteRoute = path: '/malformed', getParentRoute: () => SpecialCharsRouteRoute, } as any) +const CanonicalDeepRouteRoute = CanonicalDeepRouteRouteImport.update({ + id: '/deep', + path: '/deep', + getParentRoute: () => CanonicalRouteRoute, +} as any) const RedirectTargetIndexRoute = RedirectTargetIndexRouteImport.update({ id: '/', path: '/', @@ -377,6 +389,7 @@ const FooBarQuxHereIndexRoute = FooBarQuxHereIndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/canonical': typeof CanonicalRouteRouteWithChildren '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren '/specialChars': typeof SpecialCharsRouteRouteWithChildren @@ -391,6 +404,7 @@ export interface FileRoutesByFullPath { '/stream': typeof StreamRoute '/type-only-reexport': typeof TypeOnlyReexportRoute '/users': typeof UsersRouteWithChildren + '/canonical/deep': typeof CanonicalDeepRouteRoute '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute @@ -436,6 +450,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/canonical': typeof CanonicalRouteRouteWithChildren '/specialChars': typeof SpecialCharsRouteRouteWithChildren '/async-scripts': typeof AsyncScriptsRoute '/client-only': typeof ClientOnlyRoute @@ -445,6 +460,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 @@ -489,6 +505,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 @@ -504,6 +521,7 @@ export interface FileRoutesById { '/stream': typeof StreamRoute '/type-only-reexport': typeof TypeOnlyReexportRoute '/users': typeof UsersRouteWithChildren + '/canonical/deep': typeof CanonicalDeepRouteRoute '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren @@ -552,6 +570,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/canonical' | '/not-found' | '/search-params' | '/specialChars' @@ -566,6 +585,7 @@ export interface FileRouteTypes { | '/stream' | '/type-only-reexport' | '/users' + | '/canonical/deep' | '/specialChars/malformed' | '/api/users' | '/multi-cookie-redirect/target' @@ -611,6 +631,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/canonical' | '/specialChars' | '/async-scripts' | '/client-only' @@ -620,6 +641,7 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/type-only-reexport' + | '/canonical/deep' | '/specialChars/malformed' | '/api/users' | '/multi-cookie-redirect/target' @@ -663,6 +685,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/canonical' | '/not-found' | '/search-params' | '/specialChars' @@ -678,6 +701,7 @@ export interface FileRouteTypes { | '/stream' | '/type-only-reexport' | '/users' + | '/canonical/deep' | '/specialChars/malformed' | '/_layout/_layout-2' | '/api/users' @@ -725,6 +749,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + CanonicalRouteRoute: typeof CanonicalRouteRouteWithChildren NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren @@ -856,6 +881,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: '/' @@ -1059,6 +1091,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 + } '/redirect/$target/': { id: '/redirect/$target/' path: '/' @@ -1167,6 +1206,18 @@ declare module '@tanstack/react-router' { } } +interface CanonicalRouteRouteChildren { + CanonicalDeepRouteRoute: typeof CanonicalDeepRouteRoute +} + +const CanonicalRouteRouteChildren: CanonicalRouteRouteChildren = { + CanonicalDeepRouteRoute: CanonicalDeepRouteRoute, +} + +const CanonicalRouteRouteWithChildren = CanonicalRouteRoute._addFileChildren( + CanonicalRouteRouteChildren, +) + interface NotFoundRouteRouteChildren { NotFoundViaBeforeLoadRoute: typeof NotFoundViaBeforeLoadRoute NotFoundViaLoaderRoute: typeof NotFoundViaLoaderRoute @@ -1359,6 +1410,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 ad5f5431487..ef4ea8e08ac 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' @@ -50,6 +51,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 RedirectTargetIndexRouteImport } from './routes/redirect/$target/index' import { Route as TransitionTypingCreateResourceRouteImport } from './routes/transition/typing/create-resource' import { Route as TransitionCountCreateResourceRouteImport } from './routes/transition/count/create-resource' @@ -125,6 +127,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: '/', @@ -274,6 +281,11 @@ const SpecialCharsMalformedRouteRoute = path: '/malformed', getParentRoute: () => SpecialCharsRouteRoute, } as any) +const CanonicalDeepRouteRoute = CanonicalDeepRouteRouteImport.update({ + id: '/deep', + path: '/deep', + getParentRoute: () => CanonicalRouteRoute, +} as any) const RedirectTargetIndexRoute = RedirectTargetIndexRouteImport.update({ id: '/', path: '/', @@ -361,6 +373,7 @@ const RedirectTargetServerFnViaBeforeLoadRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/canonical': typeof CanonicalRouteRouteWithChildren '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren '/specialChars': typeof SpecialCharsRouteRouteWithChildren @@ -372,6 +385,7 @@ export interface FileRoutesByFullPath { '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren + '/canonical/deep': typeof CanonicalDeepRouteRoute '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute @@ -417,12 +431,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 '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute @@ -468,6 +484,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 @@ -480,6 +497,7 @@ export interface FileRoutesById { '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren + '/canonical/deep': typeof CanonicalDeepRouteRoute '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren @@ -528,6 +546,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/canonical' | '/not-found' | '/search-params' | '/specialChars' @@ -539,6 +558,7 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/users' + | '/canonical/deep' | '/specialChars/malformed' | '/api/users' | '/multi-cookie-redirect/target' @@ -584,12 +604,14 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/canonical' | '/specialChars' | '/deferred' | '/inline-scripts' | '/links' | '/scripts' | '/stream' + | '/canonical/deep' | '/specialChars/malformed' | '/api/users' | '/multi-cookie-redirect/target' @@ -634,6 +656,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/canonical' | '/not-found' | '/search-params' | '/specialChars' @@ -646,6 +669,7 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/users' + | '/canonical/deep' | '/specialChars/malformed' | '/_layout/_layout-2' | '/api/users' @@ -693,6 +717,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + CanonicalRouteRoute: typeof CanonicalRouteRouteWithChildren NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren @@ -801,6 +826,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: '/' @@ -1004,6 +1036,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 + } '/redirect/$target/': { id: '/redirect/$target/' path: '/' @@ -1112,6 +1151,18 @@ declare module '@tanstack/solid-router' { } } +interface CanonicalRouteRouteChildren { + CanonicalDeepRouteRoute: typeof CanonicalDeepRouteRoute +} + +const CanonicalRouteRouteChildren: CanonicalRouteRouteChildren = { + CanonicalDeepRouteRoute: CanonicalDeepRouteRoute, +} + +const CanonicalRouteRouteWithChildren = CanonicalRouteRoute._addFileChildren( + CanonicalRouteRouteChildren, +) + interface NotFoundRouteRouteChildren { NotFoundViaBeforeLoadRoute: typeof NotFoundViaBeforeLoadRoute NotFoundViaLoaderRoute: typeof NotFoundViaLoaderRoute @@ -1292,6 +1343,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 77a84e6ecaa..d6c329d14a6 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' @@ -50,6 +51,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 RedirectTargetIndexRouteImport } from './routes/redirect/$target/index' import { Route as SpecialCharsMalformedSearchRouteImport } from './routes/specialChars/malformed/search' import { Route as SpecialCharsMalformedParamRouteImport } from './routes/specialChars/malformed/$param' @@ -123,6 +125,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: '/', @@ -272,6 +279,11 @@ const SpecialCharsMalformedRouteRoute = path: '/malformed', getParentRoute: () => SpecialCharsRouteRoute, } as any) +const CanonicalDeepRouteRoute = CanonicalDeepRouteRouteImport.update({ + id: '/deep', + path: '/deep', + getParentRoute: () => CanonicalRouteRoute, +} as any) const RedirectTargetIndexRoute = RedirectTargetIndexRouteImport.update({ id: '/', path: '/', @@ -347,6 +359,7 @@ const RedirectTargetServerFnViaBeforeLoadRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/canonical': typeof CanonicalRouteRouteWithChildren '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren '/specialChars': typeof SpecialCharsRouteRouteWithChildren @@ -358,6 +371,7 @@ export interface FileRoutesByFullPath { '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren + '/canonical/deep': typeof CanonicalDeepRouteRoute '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute @@ -401,12 +415,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 '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute @@ -450,6 +466,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 @@ -462,6 +479,7 @@ export interface FileRoutesById { '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren + '/canonical/deep': typeof CanonicalDeepRouteRoute '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren @@ -508,6 +526,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/canonical' | '/not-found' | '/search-params' | '/specialChars' @@ -519,6 +538,7 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/users' + | '/canonical/deep' | '/specialChars/malformed' | '/api/users' | '/multi-cookie-redirect/target' @@ -562,12 +582,14 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/canonical' | '/specialChars' | '/deferred' | '/inline-scripts' | '/links' | '/scripts' | '/stream' + | '/canonical/deep' | '/specialChars/malformed' | '/api/users' | '/multi-cookie-redirect/target' @@ -610,6 +632,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/canonical' | '/not-found' | '/search-params' | '/specialChars' @@ -622,6 +645,7 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/users' + | '/canonical/deep' | '/specialChars/malformed' | '/_layout/_layout-2' | '/api/users' @@ -667,6 +691,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + CanonicalRouteRoute: typeof CanonicalRouteRouteWithChildren NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren @@ -773,6 +798,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: '/' @@ -976,6 +1008,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 + } '/redirect/$target/': { id: '/redirect/$target/' path: '/' @@ -1070,6 +1109,18 @@ declare module '@tanstack/vue-router' { } } +interface CanonicalRouteRouteChildren { + CanonicalDeepRouteRoute: typeof CanonicalDeepRouteRoute +} + +const CanonicalRouteRouteChildren: CanonicalRouteRouteChildren = { + CanonicalDeepRouteRoute: CanonicalDeepRouteRoute, +} + +const CanonicalRouteRouteWithChildren = CanonicalRouteRoute._addFileChildren( + CanonicalRouteRouteChildren, +) + interface NotFoundRouteRouteChildren { NotFoundViaBeforeLoadRoute: typeof NotFoundViaBeforeLoadRoute NotFoundViaLoaderRoute: typeof NotFoundViaLoaderRoute @@ -1250,6 +1301,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', + ) +}) From 7e91c99c88a3fc0536e73fc55319b51b4ae7445c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A2=85=EA=B2=BD?= Date: Wed, 25 Feb 2026 20:22:52 +0900 Subject: [PATCH 2/2] fix: Deduplicate head link tags, including canonical, by processing matches from deepest to shallowest. --- .../react-router/src/headContentUtils.tsx | 41 +++++++++++++----- .../solid-router/src/headContentUtils.tsx | 41 +++++++++++++----- packages/vue-router/src/headContentUtils.tsx | 42 ++++++++++++++----- 3 files changed, 91 insertions(+), 33 deletions(-) diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx index 1345eebb22d..bf70ff7ff55 100644 --- a/packages/react-router/src/headContentUtils.tsx +++ b/packages/react-router/src/headContentUtils.tsx @@ -90,17 +90,36 @@ 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: Record = {} + + 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 && relsToDedupe.has(link.rel)) { + if (linksByRel[link.rel]) { + continue + } + linksByRel[link.rel] = true + } + + 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..cd88ba9fc80 100644 --- a/packages/solid-router/src/headContentUtils.tsx +++ b/packages/solid-router/src/headContentUtils.tsx @@ -91,17 +91,36 @@ 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: Record = {} + + 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 && relsToDedupe.has(link.rel)) { + if (linksByRel[link.rel]) { + continue + } + linksByRel[link.rel] = true + } + + 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..d628b95cd9b 100644 --- a/packages/vue-router/src/headContentUtils.tsx +++ b/packages/vue-router/src/headContentUtils.tsx @@ -74,17 +74,37 @@ 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: Record = {} + + 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 && relsToDedupe.has(link.rel)) { + if (linksByRel[link.rel]) { + continue + } + linksByRel[link.rel] = true + } + + constructedLinks.push({ + tag: 'link', + attrs: { + ...link, + }, + }) + } + } + + constructedLinks.reverse() + return constructedLinks satisfies Array + }, }) const preloadMeta = useRouterState({