From ae2267caf5f462e5520b5c791609b43210bdc487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20=F0=9F=91=A8=F0=9F=8F=BD=E2=80=8D=F0=9F=92=BB=20Copl?= =?UTF-8?q?an?= Date: Mon, 2 Feb 2026 11:47:15 -0800 Subject: [PATCH] fix: teach to accept base tag with only 'target' attribute From the spec: > The `base` element must have an `href` attribute, a `target` attribute, or both. --- __tests__/api/base.test.tsx | 37 ++++++++++++++++++++-- __tests__/server/base.test.tsx | 58 ++++++++++++++++++++++++++++++++++ src/constants.ts | 1 + src/utils.ts | 2 +- 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/__tests__/api/base.test.tsx b/__tests__/api/base.test.tsx index e962e4a7..34953a9a 100644 --- a/__tests__/api/base.test.tsx +++ b/__tests__/api/base.test.tsx @@ -30,8 +30,21 @@ describe('base tag', () => { expect(existingTags).toHaveLength(0); }); - it('tags without \'href\' are not accepted', () => { - renderClient(); + it("tags with only 'target' are accepted", () => { + renderClient(); + const existingTags = [...document.head.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`)]; + + expect(existingTags).toBeDefined(); + expect(existingTags).toHaveLength(1); + const firstTag = existingTags[0]!; + expect(firstTag).toBeInstanceOf(Element); + expect(firstTag.getAttribute).toBeDefined(); + expect(firstTag).toHaveAttribute('target', '_blank'); + expect(firstTag).not.toHaveAttribute('href'); + }); + + it("tags without 'href' or 'target' are not accepted", () => { + renderClient(); const existingTags = document.head.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); expect(existingTags).toBeDefined(); @@ -97,7 +110,25 @@ describe('base tag', () => { expect(existingTags).toHaveLength(0); }); - it('tags without \'href\' are not accepted', () => { + it("tags with only 'target' are accepted", () => { + renderClient( + + + , + ); + + const existingTags = [...document.head.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`)]; + + expect(existingTags).toBeDefined(); + expect(existingTags).toHaveLength(1); + const firstTag = existingTags[0]!; + expect(firstTag).toBeInstanceOf(Element); + expect(firstTag.getAttribute).toBeDefined(); + expect(firstTag).toHaveAttribute('target', '_blank'); + expect(firstTag).not.toHaveAttribute('href'); + }); + + it("tags without 'href' or 'target' are not accepted", () => { /* eslint-disable react/no-unknown-property */ renderClient( diff --git a/__tests__/server/base.test.tsx b/__tests__/server/base.test.tsx index 8ffd818e..7deede99 100644 --- a/__tests__/server/base.test.tsx +++ b/__tests__/server/base.test.tsx @@ -35,6 +35,30 @@ describe('server', () => { expect(head?.base.toString).toBeDefined(); expect(head?.base.toString()).toMatchSnapshot(); }); + + it("renders base tag with only 'target' as React component", () => { + const head = renderContextServer(); + + expect(head?.base).toBeDefined(); + expect(head?.base.toComponent).toBeDefined(); + + const baseComponent = head?.base.toComponent(); + + expect(baseComponent).toStrictEqual(isArray); + expect(baseComponent).toHaveLength(1); + + const markup = renderToStaticMarkup(baseComponent); + expect(markup).toContain('target="_blank"'); + expect(markup).not.toContain('href='); + }); + + it("renders base tag with only 'target' as string", () => { + const head = renderContextServer(); + expect(head?.base).toBeDefined(); + expect(head?.base.toString).toBeDefined(); + expect(head?.base.toString()).toContain('target="_blank"'); + expect(head?.base.toString()).not.toContain('href='); + }); }); describe('Declarative API', () => { @@ -73,5 +97,39 @@ describe('server', () => { expect(head!.base.toString).toBeDefined(); expect(head?.base.toString()).toMatchSnapshot(); }); + + it("renders base tag with only 'target' as React component", () => { + const head = renderContextServer( + + + , + ); + + expect(head?.base).toBeDefined(); + expect(head?.base.toComponent).toBeDefined(); + + const baseComponent = head?.base.toComponent(); + + expect(baseComponent).toStrictEqual(isArray); + expect(baseComponent).toHaveLength(1); + + const markup = renderToStaticMarkup(baseComponent); + + expect(markup).toContain('target="_blank"'); + expect(markup).not.toContain('href='); + }); + + it("renders base tag with only 'target' as string", () => { + const head = renderContextServer( + + + , + ); + + expect(head?.base).toBeDefined(); + expect(head?.base.toString).toBeDefined(); + expect(head?.base.toString()).toContain('target="_blank"'); + expect(head?.base.toString()).not.toContain('href='); + }); }); }); diff --git a/src/constants.ts b/src/constants.ts index e7106020..d56e6b29 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,6 +9,7 @@ export enum TAG_PROPERTIES { PROPERTY = 'property', REL = 'rel', SRC = 'src', + TARGET = 'target', } export enum ATTRIBUTE_NAMES { diff --git a/src/utils.ts b/src/utils.ts index 7e638fa7..8c6e8326 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -103,7 +103,7 @@ export function aggregateBaseProps( ): BaseProps | undefined { for (let i = props.length - 1; i >= 0; --i) { const res = props[i]![1].base; - if (res?.href) return res; + if (res?.href || res?.target) return res; } return undefined; }