From 788278b29badd6e6867a4177eea035591a91b4ab 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 | 40 +++++++++++++++++++++-- __tests__/server/base.test.tsx | 59 ++++++++++++++++++++++++++++++++++ src/constants.ts | 1 + src/utils.ts | 2 +- 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/__tests__/api/base.test.tsx b/__tests__/api/base.test.tsx index 66c2ffb4..bc43f4cb 100644 --- a/__tests__/api/base.test.tsx +++ b/__tests__/api/base.test.tsx @@ -32,7 +32,20 @@ describe('base tag', () => { expect(existingTags).toHaveLength(0); }); - it("tags without 'href' are not accepted", () => { + it("tags with only 'target' are accepted", () => { + render(); + const existingTags = [...document.head.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`)]; + const [firstTag] = existingTags; + + expect(existingTags).toBeDefined(); + expect(existingTags).toHaveLength(1); + 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", () => { render(); const existingTags = document.head.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); @@ -99,7 +112,30 @@ describe('base tag', () => { expect(existingTags).toHaveLength(0); }); - it("tags without 'href' are not accepted", () => { + it("tags with only 'target' are accepted", () => { + render( + + + + ); + + const existingTags = [...document.head.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`)]; + const [firstTag] = existingTags; + + expect(existingTags).toBeDefined(); + expect(existingTags).toHaveLength(1); + expect(firstTag).toBeInstanceOf(Element); + expect(firstTag.getAttribute).toBeDefined(); + expect(firstTag).toHaveAttribute('target', '_blank'); + expect(firstTag).not.toHaveAttribute('href'); + }); + + /** + * From the spec: + * https://html.spec.whatwg.org/multipage/semantics.html#the-base-element + * > The `base` element must have either an `href` attribute, a `target` attribute, or both. + */ + it("tags without 'href' or 'target' are not accepted", () => { render( diff --git a/__tests__/server/base.test.tsx b/__tests__/server/base.test.tsx index af8b09af..3cf77a70 100644 --- a/__tests__/server/base.test.tsx +++ b/__tests__/server/base.test.tsx @@ -43,6 +43,31 @@ describe('server', () => { expect(head.base.toString).toBeDefined(); expect(head.base.toString()).toMatchSnapshot(); }); + + it("renders base tag with only 'target' as React component", () => { + const head = renderContext(); + + expect(head.base).toBeDefined(); + expect(head.base.toComponent).toBeDefined(); + + const baseComponent = head.base.toComponent(); + + expect(baseComponent).toEqual(isArray); + expect(baseComponent).toHaveLength(1); + + const markup = ReactServer.renderToStaticMarkup(baseComponent); + + expect(markup).toContain('target="_blank"'); + expect(markup).not.toContain('href='); + }); + + it("renders base tag with only 'target' as string", () => { + const head = renderContext(); + 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', () => { @@ -81,5 +106,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 = renderContext( + + + + ); + + expect(head.base).toBeDefined(); + expect(head.base.toComponent).toBeDefined(); + + const baseComponent = head.base.toComponent(); + + expect(baseComponent).toEqual(isArray); + expect(baseComponent).toHaveLength(1); + + const markup = ReactServer.renderToStaticMarkup(baseComponent); + + expect(markup).toContain('target="_blank"'); + expect(markup).not.toContain('href='); + }); + + it("renders base tag with only 'target' as string", () => { + const head = renderContext( + + + + ); + + 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 401f0ea7..b0b97d22 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 31501eee..ca6770db 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -204,7 +204,7 @@ const getAnyTrueFromPropsList = (propsList: PropsList, checkedTag: string) => { }; const reducePropsToState = (propsList: PropsList) => ({ - baseTag: getBaseTagFromPropsList([TAG_PROPERTIES.HREF], propsList), + baseTag: getBaseTagFromPropsList([TAG_PROPERTIES.HREF, TAG_PROPERTIES.TARGET], propsList), bodyAttributes: getAttributesFromPropsList(ATTRIBUTE_NAMES.BODY, propsList), defer: getInnermostProperty(propsList, HELMET_PROPS.DEFER), encode: getInnermostProperty(propsList, HELMET_PROPS.ENCODE_SPECIAL_CHARACTERS),