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({