Skip to content

Commit 1487db6

Browse files
authored
fix(unhead): dedupe link rel without hreflang or type (#658)
1 parent f988ab4 commit 1487db6

File tree

2 files changed

+107
-5
lines changed

2 files changed

+107
-5
lines changed

packages/unhead/src/utils/dedupe.ts

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

32-
const altKey = props.hreflang || props.type
33-
if (name === 'link' && props.rel === 'alternate' && altKey) {
34-
return `alternate:${altKey}`
32+
// dedupe alternate links with hreflang/type by that attribute
33+
if (name === 'link' && props.rel === 'alternate') {
34+
const altKey = props.hreflang || props.type
35+
if (altKey) {
36+
return `alternate:${altKey}`
37+
}
3538
}
3639

3740
if (props.charset)
@@ -59,6 +62,11 @@ export function dedupeKey<T extends HeadTag>(tag: T): string | undefined {
5962
return `${name}:id:${props.id}`
6063
}
6164

65+
// bare alternate links (no hreflang/type/key/id) dedupe by href
66+
if (name === 'link' && props.rel === 'alternate') {
67+
return `alternate:${props.href || ''}`
68+
}
69+
6270
// avoid duplicate tags with the same content (if no key is provided)
6371
if (TagsWithInnerContent.has(name)) {
6472
const v = tag.textContent || tag.innerHTML

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

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,7 @@ describe('dedupe', () => {
646646
expect(headTags).toContain('atom.xml')
647647
})
648648

649-
it('bare alternate without hreflang or type falls through to generic deduping', async () => {
649+
it('bare alternate without hreflang or type dedupes by href', async () => {
650650
const head = createServerHeadWithContext()
651651
head.push({
652652
link: [
@@ -655,7 +655,101 @@ describe('dedupe', () => {
655655
],
656656
})
657657
const { headTags } = await renderSSRHead(head)
658-
// both should exist since no dedupe key matched
658+
// both should exist since they have different hrefs
659659
expect(headTags.split('rel="alternate"').length).toBe(3)
660660
})
661+
662+
it('dedupes bare alternate links with same href on hydration', async () => {
663+
const head = createServerHeadWithContext()
664+
head.push({
665+
link: [
666+
{ rel: 'alternate', href: '/' },
667+
],
668+
})
669+
// Simulate hydration - push the same link again
670+
head.push({
671+
link: [
672+
{ rel: 'alternate', href: '/' },
673+
],
674+
})
675+
const { headTags } = await renderSSRHead(head)
676+
// Should only have 1 alternate link
677+
expect(headTags.split('rel="alternate"').length).toBe(2)
678+
})
679+
680+
it('dedupes bare alternate links without href on hydration', async () => {
681+
const head = createServerHeadWithContext()
682+
head.push({
683+
link: [
684+
{ rel: 'alternate' },
685+
],
686+
})
687+
// Simulate hydration - push the same link again
688+
head.push({
689+
link: [
690+
{ rel: 'alternate' },
691+
],
692+
})
693+
const { headTags } = await renderSSRHead(head)
694+
// Should only have 1 alternate link
695+
expect(headTags.split('rel="alternate"').length).toBe(2)
696+
})
697+
698+
it('dedupes alternate links by id (i18n use case)', async () => {
699+
const head = createServerHeadWithContext()
700+
head.push({
701+
link: [
702+
{ id: 'i18n-alt-nl', rel: 'alternate', href: 'http://localhost:3000/nl/products/big-chair', hreflang: 'nl' },
703+
],
704+
})
705+
// Simulate dynamic parameter translation update
706+
head.push({
707+
link: [
708+
{ id: 'i18n-alt-nl', rel: 'alternate', href: 'http://localhost:3000/nl/products/grote-stoel', hreflang: 'nl' },
709+
],
710+
})
711+
const { headTags } = await renderSSRHead(head)
712+
// Should only have 1 alternate link - last one wins via hreflang dedupe
713+
expect(headTags.split('rel="alternate"').length).toBe(2)
714+
expect(headTags).toContain('grote-stoel')
715+
expect(headTags).not.toContain('big-chair')
716+
})
717+
718+
it('alternate links with key dedupe by key', async () => {
719+
const head = createServerHeadWithContext()
720+
head.push({
721+
link: [
722+
{ key: 'my-alt', rel: 'alternate', href: 'https://example.com/a' },
723+
],
724+
})
725+
head.push({
726+
link: [
727+
{ key: 'my-alt', rel: 'alternate', href: 'https://example.com/b' },
728+
],
729+
})
730+
const { headTags } = await renderSSRHead(head)
731+
// Should have 1 alternate link deduped by key - last one wins
732+
expect(headTags.split('rel="alternate"').length).toBe(2)
733+
expect(headTags).toContain('https://example.com/b')
734+
expect(headTags).not.toContain('https://example.com/a')
735+
})
736+
737+
it('alternate links with id dedupe by id when no hreflang', async () => {
738+
const head = createServerHeadWithContext()
739+
head.push({
740+
link: [
741+
{ id: 'my-feed', rel: 'alternate', href: 'https://example.com/feed-v1.xml' },
742+
],
743+
})
744+
head.push({
745+
link: [
746+
{ id: 'my-feed', rel: 'alternate', href: 'https://example.com/feed-v2.xml' },
747+
],
748+
})
749+
const { headTags } = await renderSSRHead(head)
750+
// Should have 1 alternate link deduped by id - last one wins
751+
expect(headTags.split('rel="alternate"').length).toBe(2)
752+
expect(headTags).toContain('feed-v2.xml')
753+
expect(headTags).not.toContain('feed-v1.xml')
754+
})
661755
})

0 commit comments

Comments
 (0)