Skip to content

Commit 86175eb

Browse files
authored
fix(unhead): dedupe <link rel="alternate"> by hreflang/type only, drop href from key (#656)
Removes `href` from the alternate link dedupe key so that same-hreflang entries correctly dedupe (last push wins). Bare alternates without hreflang or type fall through to generic deduping logic.
1 parent 768053c commit 86175eb

File tree

2 files changed

+53
-7
lines changed

2 files changed

+53
-7
lines changed

packages/unhead/src/utils/dedupe.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ export function dedupeKey<T extends HeadTag>(tag: T): string | undefined {
2929
if (name === 'link' && props.rel === 'canonical')
3030
return 'canonical'
3131

32-
if (name === 'link' && props.rel === 'alternate') {
33-
return `alternate:${props.hreflang || props.type || 'x-default'}:${props.href || ''}`
32+
const altKey = props.hreflang || props.type
33+
if (name === 'link' && props.rel === 'alternate' && altKey) {
34+
return `alternate:${altKey}`
3435
}
3536

3637
if (props.charset)

packages/unhead/test/unit/server/deduping.test.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ describe('dedupe', () => {
528528
expect(headTags).toContain('https://example.com/en/page')
529529
})
530530

531-
it('allows different hrefs for same hreflang (different pages)', async () => {
531+
it('dedupes same hreflang with different hrefs', async () => {
532532
const head = createServerHeadWithContext()
533533
head.push({
534534
link: [
@@ -539,7 +539,7 @@ describe('dedupe', () => {
539539
},
540540
],
541541
})
542-
// Different href for same hreflang should be treated as different link
542+
// Same hreflang should be deduped regardless of href
543543
head.push({
544544
link: [
545545
{
@@ -550,10 +550,10 @@ describe('dedupe', () => {
550550
],
551551
})
552552
const { headTags } = await renderSSRHead(head)
553-
// Should have 2 alternate links since hrefs are different
554-
expect(headTags.split('rel="alternate"').length).toBe(3) // 2 tags + 1 base = 3 parts
555-
expect(headTags).toContain('https://example.com/en/page1')
553+
// Should have 1 alternate link - last one wins
554+
expect(headTags.split('rel="alternate"').length).toBe(2)
556555
expect(headTags).toContain('https://example.com/en/page2')
556+
expect(headTags).not.toContain('https://example.com/en/page1')
557557
})
558558

559559
it('dedupes alternate links without hreflang using href', async () => {
@@ -613,4 +613,49 @@ describe('dedupe', () => {
613613
expect(headTags).toContain('hreflang="de"')
614614
expect(headTags).toContain('hreflang="fr"')
615615
})
616+
617+
it('dedupes RSS feeds with same type', async () => {
618+
const head = createServerHeadWithContext()
619+
head.push({
620+
link: [
621+
{ rel: 'alternate', type: 'application/rss+xml', href: 'https://example.com/feed.xml', title: 'RSS Feed' },
622+
],
623+
})
624+
head.push({
625+
link: [
626+
{ rel: 'alternate', type: 'application/rss+xml', href: 'https://example.com/feed2.xml', title: 'RSS Feed 2' },
627+
],
628+
})
629+
const { headTags } = await renderSSRHead(head)
630+
expect(headTags.split('rel="alternate"').length).toBe(2)
631+
expect(headTags).toContain('feed2.xml')
632+
expect(headTags).not.toContain('feed.xml"')
633+
})
634+
635+
it('allows RSS and Atom feeds to coexist', async () => {
636+
const head = createServerHeadWithContext()
637+
head.push({
638+
link: [
639+
{ rel: 'alternate', type: 'application/rss+xml', href: 'https://example.com/rss.xml', title: 'RSS' },
640+
{ rel: 'alternate', type: 'application/atom+xml', href: 'https://example.com/atom.xml', title: 'Atom' },
641+
],
642+
})
643+
const { headTags } = await renderSSRHead(head)
644+
expect(headTags.split('rel="alternate"').length).toBe(3)
645+
expect(headTags).toContain('rss.xml')
646+
expect(headTags).toContain('atom.xml')
647+
})
648+
649+
it('bare alternate without hreflang or type falls through to generic deduping', async () => {
650+
const head = createServerHeadWithContext()
651+
head.push({
652+
link: [
653+
{ rel: 'alternate', href: 'https://example.com/a' },
654+
{ rel: 'alternate', href: 'https://example.com/b' },
655+
],
656+
})
657+
const { headTags } = await renderSSRHead(head)
658+
// both should exist since no dedupe key matched
659+
expect(headTags.split('rel="alternate"').length).toBe(3)
660+
})
616661
})

0 commit comments

Comments
 (0)