diff --git a/.eslintrc b/.eslintrc index 5a10dda5..a30cc7b5 100644 --- a/.eslintrc +++ b/.eslintrc @@ -13,19 +13,6 @@ "rules": { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error", - "prettier/prettier": [ - "error", - { - "printWidth": 80, - "tabWidth": 4, - "singleQuote": false, - "trailingComma": "none", - "bracketSpacing": false, - "semi": true, - "useTabs": false, - "parser": "babel", - "jsxBracketSameLine": false - } - ] + "prettier/prettier": "error" } } diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..89870b37 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "printWidth": 80, + "tabWidth": 4, + "singleQuote": false, + "trailingComma": "none", + "bracketSpacing": false, + "semi": true, + "useTabs": false, + "parser": "babel", + "jsxBracketSameLine": false +} diff --git a/src/Helmet.js b/src/Helmet.js index c22f79dc..3bf76749 100644 --- a/src/Helmet.js +++ b/src/Helmet.js @@ -280,7 +280,7 @@ function generateUniqueString() { ); } -function useHelmet(props) { +function useHelmet(props = {}) { const instance = useMemo(() => generateUniqueString(), []); const dispatch = useContext(HelmetContext); const called = useRef(false); @@ -291,7 +291,11 @@ function useHelmet(props) { } prevProps.current = props; const {children, ...restProps} = props; - let newProps = {...restProps}; + let newProps = { + defer: true, + encodeSpecialCharacters: true, + ...restProps + }; if (children) { newProps = mapChildrenToProps(children, newProps); } @@ -321,11 +325,6 @@ const Helmet = props => { Helmet.displayName = "Helmet"; -Helmet.defaultProps = { - defer: true, - encodeSpecialCharacters: true -}; - if (process.env.NODE_ENV !== "production") { /** * @param {Object} base: {"target": "_blank", "href": "http://mysite.com/"} diff --git a/test/HelmetHookTest.js b/test/HelmetHookTest.js new file mode 100644 index 00000000..61531f6f --- /dev/null +++ b/test/HelmetHookTest.js @@ -0,0 +1,4864 @@ +/* eslint max-nested-callbacks: [1, 7] */ +/* eslint-disable import/no-named-as-default */ + +import React from "react"; +import ReactDOM from "react-dom"; +import ReactServer from "react-dom/server"; +import {Helmet, HelmetProvider, createHelmetStore, useHelmet} from "../src"; +import {requestAnimationFrame} from "../src/HelmetUtils.js"; + +const HELMET_ATTRIBUTE = "data-react-helmet"; + +describe("useHelmet()", () => { + let headElement; + + const container = document.createElement("div"); + + beforeEach(() => { + headElement = + headElement || document.head || document.querySelector("head"); + + // resets DOM after each run + headElement.innerHTML = ""; + }); + + afterEach(() => { + ReactDOM.unmountComponentAtNode(container); + }); + + describe("api", () => { + describe("title", () => { + it("updates page title", done => { + const store = createHelmetStore(); + function HeadInfo() { + useHelmet({ + defaultTitle: "Fallback", + title: "Test Title" + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal("Test Title"); + + done(); + }); + }); + + it("updates page title with multiple children", done => { + const store = createHelmetStore(); + function HeadInfo({title}) { + useHelmet({ + title + }); + return null; + } + ReactDOM.render( + +
+ + + +
+
, + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal("Child Two Title"); + + done(); + }); + }); + + it("updates page title with multiple useHelmet()", done => { + const store = createHelmetStore(); + function HeadInfo() { + useHelmet({ + title: "Test Title" + }); + useHelmet({ + title: "Child One Title" + }); + useHelmet({ + title: "Child Two Title" + }); + return null; + } + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal("Child Two Title"); + + done(); + }); + }); + + it("updates page title with multiple useHelmet()", done => { + const store = createHelmetStore(); + function HeadInfo() { + useHelmet({ + title: "Test Title" + }); + useHelmet({ + title: "Child One Title" + }); + useHelmet({ + title: "Child Two Title" + }); + return null; + } + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal("Child Two Title"); + + done(); + }); + }); + + it("sets title based on deepest nested component", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({title: "Main Title"}); + return null; + } + + function HeadInfo2() { + useHelmet({title: "Nested Title"}); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal("Nested Title"); + + done(); + }); + }); + + it("sets title using deepest nested component with a defined title", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({title: "Main Title"}); + return null; + } + function HeadInfo2() { + useHelmet({}); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal("Main Title"); + + done(); + }); + }); + + it("sets title using multiple useHelmet() with a defined title", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({title: "Main Title"}); + useHelmet({}); + return null; + } + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal("Main Title"); + + done(); + }); + }); + + it("uses defaultTitle if no title is defined", done => { + const store = createHelmetStore(); + function HeadInfo() { + useHelmet({ + defaultTitle: "Fallback", + title: "", + titleTemplate: + "This is a %s of the titleTemplate feature" + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal("Fallback"); + + done(); + }); + }); + + it("uses a titleTemplate if defined", done => { + const store = createHelmetStore(); + function HeadInfo() { + useHelmet({ + defaultTitle: "Fallback", + title: "Test", + titleTemplate: + "This is a %s of the titleTemplate feature" + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal( + "This is a Test of the titleTemplate feature" + ); + + done(); + }); + }); + + it("replaces multiple title strings in titleTemplate", done => { + const store = createHelmetStore(); + function HeadInfo() { + useHelmet({ + title: "Test", + titleTemplate: + "This is a %s of the titleTemplate feature. Another %s." + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal( + "This is a Test of the titleTemplate feature. Another Test." + ); + + done(); + }); + }); + + it("uses a titleTemplate based on deepest nested component", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + title: "Test", + titleTemplate: + "This is a %s of the titleTemplate feature" + }); + return null; + } + function HeadInfo2() { + useHelmet({ + title: "Second Test", + titleTemplate: + "A %s using nested titleTemplate attributes" + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal( + "A Second Test using nested titleTemplate attributes" + ); + + done(); + }); + }); + + it("uses a titleTemplate based on multiple useHelmet()", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + title: "Test", + titleTemplate: + "This is a %s of the titleTemplate feature" + }); + useHelmet({ + title: "Second Test", + titleTemplate: + "A %s using nested titleTemplate attributes" + }); + return null; + } + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal( + "A Second Test using nested titleTemplate attributes" + ); + + done(); + }); + }); + + it("merges deepest component title with nearest upstream titleTemplate", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + title: "Test", + titleTemplate: + "This is a %s of the titleTemplate feature" + }); + return null; + } + function HeadInfo2() { + useHelmet({ + title: "Second Test" + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal( + "This is a Second Test of the titleTemplate feature" + ); + + done(); + }); + }); + + it("merges deepest component title with nearest upstream titleTemplate and with multiple useHelmet()", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + title: "Test", + titleTemplate: + "This is a %s of the titleTemplate feature" + }); + useHelmet({ + title: "Second Test" + }); + return null; + } + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal( + "This is a Second Test of the titleTemplate feature" + ); + + done(); + }); + }); + + it("renders dollar characters in a title correctly when titleTemplate present", done => { + const store = createHelmetStore(); + const dollarTitle = "te$t te$$t te$$$t te$$$$t"; + + function HeadInfo() { + useHelmet({ + title: dollarTitle, + titleTemplate: "This is a %s" + }); + return null; + } + + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal( + "This is a te$t te$$t te$$$t te$$$$t" + ); + + done(); + }); + }); + + it("does not encode all characters with HTML character entity equivalents", done => { + const store = createHelmetStore(); + const chineseTitle = "膣膗 鍆錌雔"; + + function HeadInfo() { + useHelmet({ + title: chineseTitle + }); + return null; + } + + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + expect(document.title).to.equal(chineseTitle); + + done(); + }); + }); + + it("page title with prop itemprop", done => { + const store = createHelmetStore(); + function HeadInfo() { + useHelmet({ + defaultTitle: "Fallback", + title: "Test Title with itemProp", + titleAttributes: {itemprop: "name"} + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const titleTag = document.getElementsByTagName("title")[0]; + expect(document.title).to.equal("Test Title with itemProp"); + expect(titleTag.getAttribute("itemprop")).to.equal("name"); + + done(); + }); + }); + }); + + describe("title attributes", () => { + beforeEach(() => { + headElement.innerHTML = `Test Title`; + }); + + it("update title attributes", done => { + const store = createHelmetStore(); + function HeadInfo() { + useHelmet({ + titleAttributes: {itemprop: "name"} + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const titleTag = document.getElementsByTagName("title")[0]; + expect(titleTag.getAttribute("itemprop")).to.equal("name"); + expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal( + "itemprop" + ); + + done(); + }); + }); + + it("sets attributes based on the deepest nested component", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + titleAttributes: { + lang: "en", + hidden: undefined + } + }); + return null; + } + function HeadInfo2() { + useHelmet({ + titleAttributes: { + lang: "ja" + } + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + const titleTag = document.getElementsByTagName("title")[0]; + expect(titleTag.getAttribute("lang")).to.equal("ja"); + expect(titleTag.getAttribute("hidden")).to.equal(""); + expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal( + "lang,hidden" + ); + + done(); + }); + }); + + it("sets attributes based on the multiple useHelmet()", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + titleAttributes: { + lang: "en", + hidden: undefined + } + }); + useHelmet({ + titleAttributes: { + lang: "ja" + } + }); + return null; + } + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + const titleTag = document.getElementsByTagName("title")[0]; + expect(titleTag.getAttribute("lang")).to.equal("ja"); + expect(titleTag.getAttribute("hidden")).to.equal(""); + expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal( + "lang,hidden" + ); + + done(); + }); + }); + + it("handles valueless attributes", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + titleAttributes: { + hidden: undefined + } + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const titleTag = document.getElementsByTagName("title")[0]; + expect(titleTag.getAttribute("hidden")).to.equal(""); + expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal( + "hidden" + ); + + done(); + }); + }); + + it("clears title attributes that are handled within helmet", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + titleAttributes: { + lang: "en", + hidden: undefined + } + }); + return null; + } + + function HeadInfo2() { + useHelmet({}); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const titleTag = document.getElementsByTagName( + "title" + )[0]; + expect(titleTag.getAttribute("lang")).to.be.null; + expect(titleTag.getAttribute("hidden")).to.be.null; + expect( + titleTag.getAttribute(HELMET_ATTRIBUTE) + ).to.equal(null); + + done(); + }); + }); + }); + }); + + describe("html attributes", () => { + it("updates html attributes", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + htmlAttributes: { + class: "myClassName", + lang: "en" + }, + lang: "en" + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + expect(htmlTag.getAttribute("class")).to.equal( + "myClassName" + ); + expect(htmlTag.getAttribute("lang")).to.equal("en"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal( + "class,lang" + ); + + done(); + }); + }); + + it("sets attributes based on the deepest nested component", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + htmlAttributes: { + lang: "en" + } + }); + return null; + } + function HeadInfo2() { + useHelmet({ + htmlAttributes: { + lang: "ja" + } + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + expect(htmlTag.getAttribute("lang")).to.equal("ja"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal( + "lang" + ); + + done(); + }); + }); + + it("sets attributes based on the multiple useHelmet()", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + htmlAttributes: { + lang: "en" + } + }); + useHelmet({ + htmlAttributes: { + lang: "ja" + } + }); + return null; + } + + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + expect(htmlTag.getAttribute("lang")).to.equal("ja"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal( + "lang" + ); + + done(); + }); + }); + + it("handles valueless attributes", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + htmlAttributes: { + amp: undefined + } + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + expect(htmlTag.getAttribute("amp")).to.equal(""); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal( + "amp" + ); + + done(); + }); + }); + + it("clears html attributes that are handled within helmet", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + htmlAttributes: { + lang: "en", + amp: undefined + } + }); + return null; + } + function HeadInfo2() { + useHelmet({}); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const htmlTag = document.getElementsByTagName( + "html" + )[0]; + expect(htmlTag.getAttribute("lang")).to.be.null; + expect(htmlTag.getAttribute("amp")).to.be.null; + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal( + null + ); + + done(); + }); + }); + }); + + it("updates with multiple additions and removals - overwrite and new", done => { + function HeadInfo1() { + useHelmet({ + htmlAttributes: { + lang: "en", + amp: undefined + } + }); + return null; + } + function HeadInfo2() { + useHelmet({ + htmlAttributes: { + lang: "ja", + id: "html-tag", + title: "html tag" + } + }); + return null; + } + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const htmlTag = document.getElementsByTagName( + "html" + )[0]; + expect(htmlTag.getAttribute("amp")).to.equal(null); + expect(htmlTag.getAttribute("lang")).to.equal("ja"); + expect(htmlTag.getAttribute("id")).to.equal("html-tag"); + expect(htmlTag.getAttribute("title")).to.equal( + "html tag" + ); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal( + "lang,id,title" + ); + + done(); + }); + }); + }); + + it("updates with multiple additions and removals - all new", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + htmlAttributes: { + lang: "en", + amp: undefined + } + }); + return null; + } + function HeadInfo2() { + useHelmet({ + htmlAttributes: { + id: "html-tag", + title: "html tag" + } + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const htmlTag = document.getElementsByTagName( + "html" + )[0]; + expect(htmlTag.getAttribute("amp")).to.equal(null); + expect(htmlTag.getAttribute("lang")).to.equal(null); + expect(htmlTag.getAttribute("id")).to.equal("html-tag"); + expect(htmlTag.getAttribute("title")).to.equal( + "html tag" + ); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal( + "id,title" + ); + + done(); + }); + }); + }); + + context("initialized outside of helmet", () => { + before(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + htmlTag.setAttribute("test", "test"); + }); + + it("attributes are not cleared", done => { + const store = createHelmetStore(); + function HeadInfo() { + useHelmet({}); + return null; + } + + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const htmlTag = document.getElementsByTagName( + "html" + )[0]; + expect(htmlTag.getAttribute("test")).to.equal("test"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal( + null + ); + + done(); + }); + }); + + it("attributes are overwritten if specified in helmet", done => { + const store = createHelmetStore(); + function HeadInfo() { + useHelmet({ + htmlAttributes: { + test: "helmet-attr" + } + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const htmlTag = document.getElementsByTagName( + "html" + )[0]; + expect(htmlTag.getAttribute("test")).to.equal( + "helmet-attr" + ); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal( + "test" + ); + + done(); + }); + }); + + it("attributes are cleared once managed in helmet", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + htmlAttributes: { + test: "helmet-attr" + } + }); + return null; + } + function HeadInfo2() { + useHelmet({}); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const htmlTag = document.getElementsByTagName( + "html" + )[0]; + expect(htmlTag.getAttribute("test")).to.equal(null); + expect( + htmlTag.getAttribute(HELMET_ATTRIBUTE) + ).to.equal(null); + + done(); + }); + }); + }); + }); + }); + + describe("onChangeClientState", () => { + it("when handling client state change, calls the function with new state, addedTags and removedTags ", done => { + const store = createHelmetStore(); + const spy = sinon.spy(); + function HeadInfo1() { + useHelmet({ + base: {href: "http://mysite.com/"}, + link: [ + { + href: "http://localhost/helmet", + rel: "canonical" + } + ], + meta: [{charset: "utf-8"}], + script: [ + { + src: "http://localhost/test.js", + type: "text/javascript" + } + ], + title: "Main Title", + onChangeClientState: spy + }); + return null; + } + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + expect(spy.called).to.equal(true); + const newState = spy.getCall(0).args[0]; + const addedTags = spy.getCall(0).args[1]; + const removedTags = spy.getCall(0).args[2]; + + expect(newState).to.deep.contain({title: "Main Title"}); + expect(newState.baseTag).to.deep.contain({ + href: "http://mysite.com/" + }); + expect(newState.metaTags).to.deep.contain({ + charset: "utf-8" + }); + expect(newState.linkTags).to.deep.contain({ + href: "http://localhost/helmet", + rel: "canonical" + }); + expect(newState.scriptTags).to.deep.contain({ + src: "http://localhost/test.js", + type: "text/javascript" + }); + + expect(addedTags).to.have.property("baseTag"); + expect(addedTags.baseTag).to.have.deep.nested.property( + "[0]" + ); + expect(addedTags.baseTag[0].outerHTML).to.equal( + `` + ); + + expect(addedTags).to.have.property("metaTags"); + expect(addedTags.metaTags).to.have.deep.nested.property( + "[0]" + ); + expect(addedTags.metaTags[0].outerHTML).to.equal( + `` + ); + + expect(addedTags).to.have.property("linkTags"); + expect(addedTags.linkTags).to.have.deep.nested.property( + "[0]" + ); + expect(addedTags.linkTags[0].outerHTML).to.equal( + `` + ); + + expect(addedTags).to.have.property("scriptTags"); + expect(addedTags.scriptTags).to.have.deep.nested.property( + "[0]" + ); + expect(addedTags.scriptTags[0].outerHTML).to.equal( + `` + ); + + expect(removedTags).to.be.empty; + + done(); + }); + }); + + it("calls the deepest defined callback with the deepest state", done => { + const store = createHelmetStore(); + const spy = sinon.spy(); + function HeadInfo1() { + useHelmet({ + title: "Main Title", + onChangeClientState: spy + }); + return null; + } + + function HeadInfo2() { + useHelmet({ + title: "Deeper Title" + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + expect(spy.callCount).to.equal(1); + expect(spy.getCall(0).args[0]).to.deep.contain({ + title: "Deeper Title" + }); + + done(); + }); + }); + + it("calls the deepest defined callback with the deepest state with multiple useHelmet()", done => { + const store = createHelmetStore(); + const spy = sinon.spy(); + function HeadInfo1() { + useHelmet({ + title: "Main Title", + onChangeClientState: spy + }); + useHelmet({ + title: "Deeper Title" + }); + return null; + } + + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + expect(spy.callCount).to.equal(1); + expect(spy.getCall(0).args[0]).to.deep.contain({ + title: "Deeper Title" + }); + + done(); + }); + }); + }); + + describe("base tag", () => { + it("updates base tag", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + base: {href: "http://mysite.com/"} + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.querySelectorAll( + `base[${HELMET_ATTRIBUTE}]` + ); + + expect(existingTags).to.not.equal(undefined); + + const filteredTags = [].slice + .call(existingTags) + .filter(tag => { + return ( + tag.getAttribute("href") === + "http://mysite.com/" + ); + }); + + expect(filteredTags.length).to.equal(1); + + done(); + }); + }); + + it("clears the base tag if one is not specified", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + base: {href: "http://mysite.com/"} + }); + return null; + } + function HeadInfo2() { + useHelmet({}); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.querySelectorAll( + `base[${HELMET_ATTRIBUTE}]` + ); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'href' are not accepted", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + base: {property: "won't work"} + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.querySelectorAll( + `base[${HELMET_ATTRIBUTE}]` + ); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("sets base tag based on deepest nested component", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + base: {href: "http://mysite.com/"} + }); + return null; + } + function HeadInfo2() { + useHelmet({ + base: {href: "http://mysite.com/public"} + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.querySelectorAll( + `base[${HELMET_ATTRIBUTE}]` + ); + const firstTag = Array.prototype.slice.call( + existingTags + )[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(1); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("href")).to.equal( + "http://mysite.com/public" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("does not render tag when primary attribute is null", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + base: {href: undefined} + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `base[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + done(); + }); + }); + }); + + describe("meta tags", () => { + it("updates meta tags", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + meta: [ + {charset: "utf-8"}, + { + name: "description", + content: "Test description" + }, + { + "http-equiv": "content-type", + content: "text/html" + }, + {property: "og:type", content: "article"}, + { + itemprop: "name", + content: "Test name itemprop" + } + ] + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `meta[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + + expect(existingTags).to.not.equal(undefined); + + const filteredTags = [].slice + .call(existingTags) + .filter(tag => { + return ( + tag.getAttribute("charset") === "utf-8" || + (tag.getAttribute("name") === "description" && + tag.getAttribute("content") === + "Test description") || + (tag.getAttribute("http-equiv") === + "content-type" && + tag.getAttribute("content") === + "text/html") || + (tag.getAttribute("itemprop") === "name" && + tag.getAttribute("content") === + "Test name itemprop") + ); + }); + + expect(filteredTags.length).to.be.at.least(4); + + done(); + }); + }); + + it("clears all meta tags if none are specified", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + meta: [ + { + name: "description", + content: "Test description" + } + ] + }); + return null; + } + function HeadInfo2() { + useHelmet({}); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.querySelectorAll( + `meta[${HELMET_ATTRIBUTE}]` + ); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'name', 'http-equiv', 'property', 'charset', or 'itemprop' are not accepted", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + meta: [{href: "won't work"}] + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.querySelectorAll( + `meta[${HELMET_ATTRIBUTE}]` + ); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("sets meta tags based on deepest nested component", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + meta: [ + {charset: "utf-8"}, + { + name: "description", + content: "Test description" + } + ] + }); + return null; + } + + function HeadInfo2() { + useHelmet({ + meta: [ + { + name: "description", + content: "Inner description" + }, + { + name: "keywords", + content: "test,meta,tags" + } + ] + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `meta[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + const thirdTag = existingTags[2]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(3); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("charset")).to.equal("utf-8"); + expect(firstTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("name")).to.equal( + "description" + ); + expect(secondTag.getAttribute("content")).to.equal( + "Inner description" + ); + expect(secondTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[2]") + .that.is.an.instanceof(Element); + expect(thirdTag).to.have.property("getAttribute"); + expect(thirdTag.getAttribute("name")).to.equal("keywords"); + expect(thirdTag.getAttribute("content")).to.equal( + "test,meta,tags" + ); + expect(thirdTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("sets meta tags based on multiple useHelmet()", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + meta: [ + {charset: "utf-8"}, + { + name: "description", + content: "Test description" + } + ] + }); + useHelmet({ + meta: [ + { + name: "description", + content: "Inner description" + }, + { + name: "keywords", + content: "test,meta,tags" + } + ] + }); + return null; + } + + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `meta[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + const thirdTag = existingTags[2]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(3); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("charset")).to.equal("utf-8"); + expect(firstTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("name")).to.equal( + "description" + ); + expect(secondTag.getAttribute("content")).to.equal( + "Inner description" + ); + expect(secondTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[2]") + .that.is.an.instanceof(Element); + expect(thirdTag).to.have.property("getAttribute"); + expect(thirdTag.getAttribute("name")).to.equal("keywords"); + expect(thirdTag.getAttribute("content")).to.equal( + "test,meta,tags" + ); + expect(thirdTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("allows duplicate meta tags if specified in the same component", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + meta: [ + { + name: "description", + content: "Test description" + }, + { + name: "description", + content: "Duplicate description" + } + ] + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `meta[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(2); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("name")).to.equal( + "description" + ); + expect(firstTag.getAttribute("content")).to.equal( + "Test description" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("name")).to.equal( + "description" + ); + expect(secondTag.getAttribute("content")).to.equal( + "Duplicate description" + ); + expect(secondTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("overrides duplicate meta tags with single meta tag in a nested component", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + meta: [ + { + name: "description", + content: "Test description" + }, + { + name: "description", + content: "Duplicate description" + } + ] + }); + return null; + } + function HeadInfo2() { + useHelmet({ + meta: [ + { + name: "description", + content: "Inner description" + } + ] + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `meta[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(1); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("name")).to.equal( + "description" + ); + expect(firstTag.getAttribute("content")).to.equal( + "Inner description" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("overrides duplicate meta tags with single meta tag in a nested component with multiple useHelmet()", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + meta: [ + { + name: "description", + content: "Test description" + }, + { + name: "description", + content: "Duplicate description" + } + ] + }); + useHelmet({ + meta: [ + { + name: "description", + content: "Inner description" + } + ] + }); + return null; + } + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `meta[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(1); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("name")).to.equal( + "description" + ); + expect(firstTag.getAttribute("content")).to.equal( + "Inner description" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("overrides single meta tag with duplicate meta tags in a nested component", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + meta: [ + { + name: "description", + content: "Test description" + } + ] + }); + return null; + } + + function HeadInfo2() { + useHelmet({ + meta: [ + { + name: "description", + content: "Inner description" + }, + { + name: "description", + content: "Inner duplicate description" + } + ] + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `meta[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(2); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("name")).to.equal( + "description" + ); + expect(firstTag.getAttribute("content")).to.equal( + "Inner description" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("name")).to.equal( + "description" + ); + expect(secondTag.getAttribute("content")).to.equal( + "Inner duplicate description" + ); + expect(secondTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("overrides single meta tag with duplicate meta tags in a nested component with multiple useHelmet()", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + meta: [ + { + name: "description", + content: "Test description" + } + ] + }); + useHelmet({ + meta: [ + { + name: "description", + content: "Inner description" + }, + { + name: "description", + content: "Inner duplicate description" + } + ] + }); + return null; + } + + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `meta[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(2); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("name")).to.equal( + "description" + ); + expect(firstTag.getAttribute("content")).to.equal( + "Inner description" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("name")).to.equal( + "description" + ); + expect(secondTag.getAttribute("content")).to.equal( + "Inner duplicate description" + ); + expect(secondTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("does not render tag when primary attribute is null", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + meta: [ + { + name: undefined, + content: "Inner duplicate description" + } + ] + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `meta[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + done(); + }); + }); + + it("fails gracefully when meta is wrong shape", done => { + const store = createHelmetStore(); + const error = sinon.stub(console, "error"); + const warn = sinon.stub(console, "warn"); + + function HeadInfo1() { + useHelmet({ + meta: {name: "title", content: "some title"} + }); + return null; + } + + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `meta[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + expect(warn.called).to.be.true; + + const [warning] = warn.getCall(0).args; + expect(warning).to.equal( + `Helmet: meta should be of type "Array". Instead found type "object"` + ); + + error.restore(); + warn.restore(); + + done(); + }); + }); + }); + + describe("link tags", () => { + it("updates link tags", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + link: [ + { + href: "http://localhost/helmet", + rel: "canonical" + }, + { + href: "http://localhost/style.css", + rel: "stylesheet", + type: "text/css" + } + ] + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `link[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + + expect(existingTags).to.not.equal(undefined); + + const filteredTags = [].slice + .call(existingTags) + .filter(tag => { + return ( + (tag.getAttribute("href") === + "http://localhost/style.css" && + tag.getAttribute("rel") === "stylesheet" && + tag.getAttribute("type") === "text/css") || + (tag.getAttribute("href") === + "http://localhost/helmet" && + tag.getAttribute("rel") === "canonical") + ); + }); + + expect(filteredTags.length).to.be.at.least(2); + + done(); + }); + }); + + it("clears all link tags if none are specified", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + link: [ + { + href: "http://localhost/helmet", + rel: "canonical" + } + ] + }); + return null; + } + function HeadInfo2() { + useHelmet({}); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `link[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call( + tagNodes + ); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'href' or 'rel' are not accepted, even if they are valid for other tags", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + link: [{"http-equiv": "won't work"}] + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `link[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("tags 'rel' and 'href' properly use 'rel' as the primary identification for this tag, regardless of ordering", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + link: [ + { + href: "http://localhost/helmet", + rel: "canonical" + } + ] + }); + return null; + } + function HeadInfo2() { + useHelmet({ + link: [ + { + rel: "canonical", + href: "http://localhost/helmet/new" + } + ] + }); + return null; + } + function HeadInfo3() { + useHelmet({ + link: [ + { + href: "http://localhost/helmet/newest", + rel: "canonical" + } + ] + }); + return null; + } + ReactDOM.render( + +
+ + + +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `link[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(1); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal( + "http://localhost/helmet/newest" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("tags 'rel' and 'href' properly use 'rel' as the primary identification for this tag, regardless of ordering, using multiple useHelmet()", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + link: [ + { + href: "http://localhost/helmet", + rel: "canonical" + } + ] + }); + useHelmet({ + link: [ + { + rel: "canonical", + href: "http://localhost/helmet/new" + } + ] + }); + useHelmet({ + link: [ + { + href: "http://localhost/helmet/newest", + rel: "canonical" + } + ] + }); + return null; + } + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `link[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(1); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal( + "http://localhost/helmet/newest" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("tags with rel='stylesheet' uses the href as the primary identification of the tag, regardless of ordering", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + link: [ + { + href: "http://localhost/style.css", + rel: "stylesheet", + type: "text/css", + media: "all" + } + ] + }); + return null; + } + function HeadInfo2() { + useHelmet({ + link: [ + { + rel: "stylesheet", + href: "http://localhost/inner.css", + type: "text/css", + media: "all" + } + ] + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `link[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(2); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("href")).to.equal( + "http://localhost/style.css" + ); + expect(firstTag.getAttribute("rel")).to.equal("stylesheet"); + expect(firstTag.getAttribute("type")).to.equal("text/css"); + expect(firstTag.getAttribute("media")).to.equal("all"); + expect(firstTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("rel")).to.equal( + "stylesheet" + ); + expect(secondTag.getAttribute("href")).to.equal( + "http://localhost/inner.css" + ); + expect(secondTag.getAttribute("type")).to.equal("text/css"); + expect(secondTag.getAttribute("media")).to.equal("all"); + expect(secondTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("tags with rel='stylesheet' uses the href as the primary identification of the tag, regardless of ordering, with multiple useHelmet()", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + link: [ + { + href: "http://localhost/style.css", + rel: "stylesheet", + type: "text/css", + media: "all" + } + ] + }); + useHelmet({ + link: [ + { + rel: "stylesheet", + href: "http://localhost/inner.css", + type: "text/css", + media: "all" + } + ] + }); + return null; + } + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `link[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(2); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("href")).to.equal( + "http://localhost/style.css" + ); + expect(firstTag.getAttribute("rel")).to.equal("stylesheet"); + expect(firstTag.getAttribute("type")).to.equal("text/css"); + expect(firstTag.getAttribute("media")).to.equal("all"); + expect(firstTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("rel")).to.equal( + "stylesheet" + ); + expect(secondTag.getAttribute("href")).to.equal( + "http://localhost/inner.css" + ); + expect(secondTag.getAttribute("type")).to.equal("text/css"); + expect(secondTag.getAttribute("media")).to.equal("all"); + expect(secondTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("sets link tags based on deepest nested component", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + link: [ + { + rel: "canonical", + href: "http://localhost/helmet" + }, + { + href: "http://localhost/style.css", + rel: "stylesheet", + type: "text/css", + media: "all" + } + ] + }); + + return null; + } + function HeadInfo2() { + useHelmet({ + link: [ + { + rel: "canonical", + href: "http://localhost/helmet/innercomponent" + }, + { + href: "http://localhost/inner.css", + rel: "stylesheet", + type: "text/css", + media: "all" + } + ] + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `link[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + const thirdTag = existingTags[2]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.at.least(2); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("href")).to.equal( + "http://localhost/style.css" + ); + expect(firstTag.getAttribute("rel")).to.equal("stylesheet"); + expect(firstTag.getAttribute("type")).to.equal("text/css"); + expect(firstTag.getAttribute("media")).to.equal("all"); + expect(firstTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("href")).to.equal( + "http://localhost/helmet/innercomponent" + ); + expect(secondTag.getAttribute("rel")).to.equal("canonical"); + expect(secondTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[2]") + .that.is.an.instanceof(Element); + expect(thirdTag).to.have.property("getAttribute"); + expect(thirdTag.getAttribute("href")).to.equal( + "http://localhost/inner.css" + ); + expect(thirdTag.getAttribute("rel")).to.equal("stylesheet"); + expect(thirdTag.getAttribute("type")).to.equal("text/css"); + expect(thirdTag.getAttribute("media")).to.equal("all"); + expect(thirdTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("allows duplicate link tags if specified in the same component", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + link: [ + { + rel: "canonical", + href: "http://localhost/helmet" + }, + { + rel: "canonical", + href: "http://localhost/helmet/component" + } + ] + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `link[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.at.least(2); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal( + "http://localhost/helmet" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("rel")).to.equal("canonical"); + expect(secondTag.getAttribute("href")).to.equal( + "http://localhost/helmet/component" + ); + expect(secondTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("overrides duplicate link tags with a single link tag in a nested component", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + link: [ + { + rel: "canonical", + href: "http://localhost/helmet" + }, + { + rel: "canonical", + href: "http://localhost/helmet/component" + } + ] + }); + return null; + } + function HeadInfo2() { + useHelmet({ + link: [ + { + rel: "canonical", + href: "http://localhost/helmet/innercomponent" + } + ] + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `link[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(1); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal( + "http://localhost/helmet/innercomponent" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("overrides duplicate link tags with a single link tag with multiple useHelmet()", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + link: [ + { + rel: "canonical", + href: "http://localhost/helmet" + } + ] + }); + useHelmet({ + link: [ + { + rel: "canonical", + href: "http://localhost/helmet/component" + } + ] + }); + return null; + } + function HeadInfo2() { + useHelmet({ + link: [ + { + rel: "canonical", + href: "http://localhost/helmet/innercomponent" + } + ] + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `link[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(1); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal( + "http://localhost/helmet/innercomponent" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("overrides single link tag with duplicate link tags in a nested component", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + link: [ + { + rel: "canonical", + href: "http://localhost/helmet" + } + ] + }); + return null; + } + function HeadInfo2() { + useHelmet({ + link: [ + { + rel: "canonical", + href: "http://localhost/helmet/component" + }, + { + rel: "canonical", + href: "http://localhost/helmet/innercomponent" + } + ] + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `link[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(2); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal( + "http://localhost/helmet/component" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("rel")).to.equal("canonical"); + expect(secondTag.getAttribute("href")).to.equal( + "http://localhost/helmet/innercomponent" + ); + expect(secondTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("does not render tag when primary attribute is null", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + link: [ + {rel: "icon", sizes: "192x192", href: null}, + { + rel: "canonical", + href: "http://localhost/helmet/component" + } + ] + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `link[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.be.equal(1); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal( + "http://localhost/helmet/component" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + }); + + describe("script tags", () => { + it("updates script tags", done => { + const store = createHelmetStore(); + const scriptInnerHTML = ` + { + "@context": "http://schema.org", + "@type": "NewsArticle", + "url": "http://localhost/helmet" + } + `; + function HeadInfo1() { + useHelmet({ + script: [ + { + src: "http://localhost/test.js", + type: "text/javascript" + }, + { + src: "http://localhost/test2.js", + type: "text/javascript" + }, + { + type: "application/ld+json", + innerHTML: scriptInnerHTML + } + ] + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.getElementsByTagName( + "script" + ); + + expect(existingTags).to.not.equal(undefined); + + const filteredTags = [].slice + .call(existingTags) + .filter(tag => { + return ( + (tag.getAttribute("src") === + "http://localhost/test.js" && + tag.getAttribute("type") === + "text/javascript") || + (tag.getAttribute("src") === + "http://localhost/test2.js" && + tag.getAttribute("type") === + "text/javascript") || + (tag.getAttribute("type") === + "application/ld+json" && + tag.innerHTML === scriptInnerHTML) + ); + }); + + expect(filteredTags.length).to.be.at.least(3); + + done(); + }); + }); + + it("clears all scripts tags if none are specified", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + script: [ + { + src: "http://localhost/test.js", + type: "text/javascript" + } + ] + }); + return null; + } + function HeadInfo2() { + useHelmet({}); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.querySelectorAll( + `script[${HELMET_ATTRIBUTE}]` + ); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'src' are not accepted", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + script: [{property: "won't work"}] + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.querySelectorAll( + `script[${HELMET_ATTRIBUTE}]` + ); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("sets script tags based on deepest nested component", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + script: [ + { + src: "http://localhost/test.js", + type: "text/javascript" + } + ] + }); + return null; + } + function HeadInfo2() { + useHelmet({ + script: [ + { + src: "http://localhost/test2.js", + type: "text/javascript" + } + ] + }); + return null; + } + ReactDOM.render( + +
+ + +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `script[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.at.least(2); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("src")).to.equal( + "http://localhost/test.js" + ); + expect(firstTag.getAttribute("type")).to.equal( + "text/javascript" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("src")).to.equal( + "http://localhost/test2.js" + ); + expect(secondTag.getAttribute("type")).to.equal( + "text/javascript" + ); + expect(secondTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("sets script tags based on multiple useHelmet()", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + script: [ + { + src: "http://localhost/test.js", + type: "text/javascript" + } + ] + }); + useHelmet({ + script: [ + { + src: "http://localhost/test2.js", + type: "text/javascript" + } + ] + }); + return null; + } + ReactDOM.render( + +
+ +
+
, + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `script[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.at.least(2); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("src")).to.equal( + "http://localhost/test.js" + ); + expect(firstTag.getAttribute("type")).to.equal( + "text/javascript" + ); + expect(firstTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("src")).to.equal( + "http://localhost/test2.js" + ); + expect(secondTag.getAttribute("type")).to.equal( + "text/javascript" + ); + expect(secondTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("sets undefined attribute values to empty strings", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + script: [ + { + src: "foo.js", + async: undefined + } + ] + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTag = headElement.querySelector( + `script[${HELMET_ATTRIBUTE}]` + ); + + expect(existingTag).to.not.equal(undefined); + expect(existingTag.outerHTML) + .to.be.a("string") + .that.equals( + `` + ); + + done(); + }); + }); + + it("does not render tag when primary attribute (src) is null", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + script: [ + { + src: undefined, + type: "text/javascript" + } + ] + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `script[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + done(); + }); + }); + + it("does not render tag when primary attribute (innerHTML) is null", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + script: [ + { + innerHTML: undefined + } + ] + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `script[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + done(); + }); + }); + }); + + describe("noscript tags", () => { + it("updates noscript tags", done => { + const store = createHelmetStore(); + const noscriptInnerHTML = ``; + function HeadInfo1() { + useHelmet({ + noscript: [{id: "bar", innerHTML: noscriptInnerHTML}] + }); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.getElementsByTagName( + "noscript" + ); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(1); + expect( + existingTags[0].innerHTML === noscriptInnerHTML && + existingTags[0].id === "bar" + ); + + done(); + }); + }); + + it("clears all noscripts tags if none are specified", done => { + const store = createHelmetStore(); + function HeadInfo1() { + useHelmet({ + noscript: [{id: "bar"}] + }); + return null; + } + function HeadInfo2() { + useHelmet({}); + return null; + } + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.querySelectorAll( + `script[${HELMET_ATTRIBUTE}]` + ); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'innerHTML' are not accepted", done => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.querySelectorAll( + `noscript[${HELMET_ATTRIBUTE}]` + ); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("does not render tag when primary attribute is null", done => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `noscript[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + done(); + }); + }); + }); + + describe("style tags", () => { + it("updates style tags", done => { + const store = createHelmetStore(); + const cssText1 = ` + body { + background-color: green; + } + `; + const cssText2 = ` + p { + font-size: 12px; + } + `; + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `style[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + + const [firstTag, secondTag] = existingTags; + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.be.equal(2); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("type")).to.equal("text/css"); + expect(firstTag.innerHTML).to.equal(cssText1); + expect(firstTag.outerHTML).to.equal( + `` + ); + + expect(existingTags) + .to.have.deep.nested.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag.innerHTML).to.equal(cssText2); + expect(secondTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("clears all style tags if none are specified", done => { + const store = createHelmetStore(); + const cssText = ` + body { + background-color: green; + } + `; + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.querySelectorAll( + `style[${HELMET_ATTRIBUTE}]` + ); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'cssText' are not accepted", done => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.querySelectorAll( + `style[${HELMET_ATTRIBUTE}]` + ); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("does not render tag when primary attribute is null", done => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const tagNodes = headElement.querySelectorAll( + `style[${HELMET_ATTRIBUTE}]` + ); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + done(); + }); + }); + }); + }); + + describe("deferred tags", () => { + beforeEach(() => { + window.__spy__ = sinon.spy(); + }); + + afterEach(() => { + delete window.__spy__; + }); + + it("executes synchronously when defer={true} and async otherwise", done => { + const store = createHelmetStore(); + ReactDOM.render( + +
+ + +
+
, + container + ); + + expect(window.__spy__.callCount).to.equal(1); + + requestAnimationFrame(() => { + expect(window.__spy__.callCount).to.equal(2); + expect(window.__spy__.args).to.deep.equal([[1], [2]]); + done(); + }); + }); + }); + + describe("server", () => { + const stringifiedHtmlAttributes = `lang="ga" class="myClassName"`; + const stringifiedTitle = `Dangerous <script> include`; + const unEncodedStringifiedTitle = `This is text and & and '.`; + const stringifiedTitleWithItemprop = `Title with Itemprop`; + const stringifiedBaseTag = ``; + + const stringifiedMetaTags = [ + ``, + ``, + ``, + ``, + `` + ].join(""); + + const stringifiedLinkTags = [ + ``, + `` + ].join(""); + + const stringifiedScriptTags = [ + ``, + `` + ].join(""); + + const stringifiedNoscriptTags = [ + ``, + `` + ].join(""); + + const stringifiedStyleTags = [ + ``, + `` + ].join(""); + + it("provides initial values if no state is found", () => { + const store = createHelmetStore(); + let head = store.renderStatic(); + head = store.renderStatic(); + + expect(head.meta).to.exist; + expect(head.meta).to.respondTo("toString"); + + expect(head.meta.toString()).to.equal(""); + }); + + it("encodes special characters in title", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const head = store.renderStatic(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + + expect(head.title.toString()).to.equal(stringifiedTitle); + }); + + it("opts out of string encoding", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const head = store.renderStatic(); + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + + expect(head.title.toString()).to.equal(unEncodedStringifiedTitle); + }); + + it("renders title as React component", () => { + const store = createHelmetStore(); + ReactDOM.render( + + include"} /> + , + container + ); + + const head = store.renderStatic(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toComponent"); + + const titleComponent = head.title.toComponent(); + + expect(titleComponent) + .to.be.an("array") + .that.has.lengthOf(1); + + titleComponent.forEach(title => { + expect(title) + .to.be.an("object") + .that.contains.property("type", "title"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
{titleComponent}
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${stringifiedTitle}
`); + }); + + it("renders title with itemprop name as React component", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const head = store.renderStatic(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toComponent"); + + const titleComponent = head.title.toComponent(); + + expect(titleComponent) + .to.be.an("array") + .that.has.lengthOf(1); + + titleComponent.forEach(title => { + expect(title) + .to.be.an("object") + .that.contains.property("type", "title"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
{titleComponent}
+ ); + + expect(markup.toLowerCase()) + .to.be.a("string") + .that.equals( + `
${stringifiedTitleWithItemprop}
`.toLowerCase() + ); + }); + + it("renders base tag as React component", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const head = store.renderStatic(); + + expect(head.base).to.exist; + expect(head.base).to.respondTo("toComponent"); + + const baseComponent = head.base.toComponent(); + + expect(baseComponent) + .to.be.an("array") + .that.has.lengthOf(1); + + baseComponent.forEach(base => { + expect(base) + .to.be.an("object") + .that.contains.property("type", "base"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
{baseComponent}
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${stringifiedBaseTag}
`); + }); + + it("renders meta tags as React components", () => { + const store = createHelmetStore(); + ReactDOM.render( + + < `" + }, + { + "http-equiv": "content-type", + content: "text/html" + }, + {property: "og:type", content: "article"}, + {itemprop: "name", content: "Test name itemprop"} + ]} + /> + , + container + ); + + const head = store.renderStatic(); + + expect(head.meta).to.exist; + expect(head.meta).to.respondTo("toComponent"); + + const metaComponent = head.meta.toComponent(); + + expect(metaComponent) + .to.be.an("array") + .that.has.lengthOf(5); + + metaComponent.forEach(meta => { + expect(meta) + .to.be.an("object") + .that.contains.property("type", "meta"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
{metaComponent}
+ ); + + expect(markup.toLowerCase()) + .to.be.a("string") + .that.equals(`
${stringifiedMetaTags}
`.toLowerCase()); + }); + + it("renders link tags as React components", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const head = store.renderStatic(); + + expect(head.link).to.exist; + expect(head.link).to.respondTo("toComponent"); + + const linkComponent = head.link.toComponent(); + + expect(linkComponent) + .to.be.an("array") + .that.has.lengthOf(2); + + linkComponent.forEach(link => { + expect(link) + .to.be.an("object") + .that.contains.property("type", "link"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
{linkComponent}
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${stringifiedLinkTags}
`); + }); + + it("renders script tags as React components", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const head = store.renderStatic(); + + expect(head.script).to.exist; + expect(head.script).to.respondTo("toComponent"); + + const scriptComponent = head.script.toComponent(); + + expect(scriptComponent) + .to.be.an("array") + .that.has.lengthOf(2); + + scriptComponent.forEach(script => { + expect(script) + .to.be.an("object") + .that.contains.property("type", "script"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
{scriptComponent}
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${stringifiedScriptTags}
`); + }); + + it("renders noscript tags as React components", () => { + const store = createHelmetStore(); + ReactDOM.render( + + ' + }, + { + id: "bar", + innerHTML: + '' + } + ]} + /> + , + container + ); + + const head = store.renderStatic(); + + expect(head.noscript).to.exist; + expect(head.noscript).to.respondTo("toComponent"); + + const noscriptComponent = head.noscript.toComponent(); + + expect(noscriptComponent) + .to.be.an("array") + .that.has.lengthOf(2); + + noscriptComponent.forEach(noscript => { + expect(noscript) + .to.be.an("object") + .that.contains.property("type", "noscript"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
{noscriptComponent}
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${stringifiedNoscriptTags}
`); + }); + + it("renders style tags as React components", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const head = store.renderStatic(); + + expect(head.style).to.exist; + expect(head.style).to.respondTo("toComponent"); + + const styleComponent = head.style.toComponent(); + + expect(styleComponent) + .to.be.an("array") + .that.has.lengthOf(2); + + const markup = ReactServer.renderToStaticMarkup( +
{styleComponent}
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${stringifiedStyleTags}
`); + }); + + it("renders title tag as string", () => { + const store = createHelmetStore(); + ReactDOM.render( + + include"} /> + , + container + ); + + const head = store.renderStatic(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + + expect(head.title.toString()) + .to.be.a("string") + .that.equals(stringifiedTitle); + }); + + it("renders title with itemprop name as string", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const head = store.renderStatic(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + + const titleString = head.title.toString(); + expect(titleString) + .to.be.a("string") + .that.equals(stringifiedTitleWithItemprop); + }); + + it("renders base tags as string", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const head = store.renderStatic(); + + expect(head.base).to.exist; + expect(head.base).to.respondTo("toString"); + + expect(head.base.toString()) + .to.be.a("string") + .that.equals(stringifiedBaseTag); + }); + + it("renders meta tags as string", () => { + const store = createHelmetStore(); + ReactDOM.render( + + < `" + }, + { + "http-equiv": "content-type", + content: "text/html" + }, + {property: "og:type", content: "article"}, + {itemprop: "name", content: "Test name itemprop"} + ]} + /> + , + container + ); + + const head = store.renderStatic(); + + expect(head.meta).to.exist; + expect(head.meta).to.respondTo("toString"); + + expect(head.meta.toString()) + .to.be.a("string") + .that.equals(stringifiedMetaTags); + }); + + it("renders link tags as string", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const head = store.renderStatic(); + + expect(head.link).to.exist; + expect(head.link).to.respondTo("toString"); + + expect(head.link.toString()) + .to.be.a("string") + .that.equals(stringifiedLinkTags); + }); + + it("renders script tags as string", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const head = store.renderStatic(); + + expect(head.script).to.exist; + expect(head.script).to.respondTo("toString"); + + expect(head.script.toString()) + .to.be.a("string") + .that.equals(stringifiedScriptTags); + }); + + it("renders style tags as string", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const head = store.renderStatic(); + + expect(head.style).to.exist; + expect(head.style).to.respondTo("toString"); + + expect(head.style.toString()) + .to.be.a("string") + .that.equals(stringifiedStyleTags); + }); + + it("renders html attributes as component", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const {htmlAttributes} = store.renderStatic(); + const attrs = htmlAttributes.toComponent(); + + expect(attrs).to.exist; + + const markup = ReactServer.renderToStaticMarkup( + + ); + + expect(markup) + .to.be.a("string") + .that.equals(``); + }); + + it("renders html attributes as string", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const head = store.renderStatic(); + + expect(head.htmlAttributes).to.exist; + expect(head.htmlAttributes).to.respondTo("toString"); + + expect(head.htmlAttributes.toString()) + .to.be.a("string") + .that.equals(stringifiedHtmlAttributes); + }); + + it("does not encode all characters with HTML character entity equivalents", () => { + const chineseTitle = "膣膗 鍆錌雔"; + const stringifiedChineseTitle = `${chineseTitle}`; + + const store = createHelmetStore(); + ReactDOM.render( + +
+ +
+
, + container + ); + + const head = store.renderStatic(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + + expect(head.title.toString()) + .to.be.a("string") + .that.equals(stringifiedChineseTitle); + }); + + it("rewind() provides a fallback object for empty Helmet state", () => { + const store = createHelmetStore(); + ReactDOM.render( + +
+ , + container + ); + + const head = store.renderStatic(); + + expect(head.htmlAttributes).to.exist; + expect(head.htmlAttributes).to.respondTo("toString"); + expect(head.htmlAttributes.toString()).to.equal(""); + expect(head.htmlAttributes).to.respondTo("toComponent"); + expect(head.htmlAttributes.toComponent()).to.be.an("object").that.is + .empty; + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + expect(head.title.toString()).to.equal( + `` + ); + expect(head.title).to.respondTo("toComponent"); + + const markup = ReactServer.renderToStaticMarkup( +
{head.title.toComponent()}
+ ); + + expect(markup) + .to.be.a("string") + .that.equals( + `
` + ); + + expect(head.base).to.exist; + expect(head.base).to.respondTo("toString"); + expect(head.base.toString()).to.equal(""); + expect(head.base).to.respondTo("toComponent"); + expect(head.base.toComponent()).to.be.an("array").that.is.empty; + + expect(head.meta).to.exist; + expect(head.meta).to.respondTo("toString"); + expect(head.meta.toString()).to.equal(""); + expect(head.meta).to.respondTo("toComponent"); + expect(head.meta.toComponent()).to.be.an("array").that.is.empty; + + expect(head.link).to.exist; + expect(head.link).to.respondTo("toString"); + expect(head.link.toString()).to.equal(""); + expect(head.link).to.respondTo("toComponent"); + expect(head.link.toComponent()).to.be.an("array").that.is.empty; + + expect(head.script).to.exist; + expect(head.script).to.respondTo("toString"); + expect(head.script.toString()).to.equal(""); + expect(head.script).to.respondTo("toComponent"); + expect(head.script.toComponent()).to.be.an("array").that.is.empty; + + expect(head.noscript).to.exist; + expect(head.noscript).to.respondTo("toString"); + expect(head.noscript.toString()).to.equal(""); + expect(head.noscript).to.respondTo("toComponent"); + expect(head.noscript.toComponent()).to.be.an("array").that.is.empty; + + expect(head.style).to.exist; + expect(head.style).to.respondTo("toString"); + expect(head.style.toString()).to.equal(""); + expect(head.style).to.respondTo("toComponent"); + expect(head.style.toComponent()).to.be.an("array").that.is.empty; + }); + + it("does not render undefined attribute values", () => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + const {script} = store.renderStatic(); + const stringifiedScriptTag = script.toString(); + + expect(stringifiedScriptTag) + .to.be.a("string") + .that.equals( + `` + ); + }); + }); + + describe("misc", () => { + it("lets you read current state in peek() whether or not a DOM is present", done => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + expect(store.peek().title).to.be.equal("Fancy title"); + done(); + }); + }); + + it("encodes special characters", done => { + const store = createHelmetStore(); + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + const existingTags = headElement.querySelectorAll( + `meta[${HELMET_ATTRIBUTE}]` + ); + const existingTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(1); + + expect(existingTags) + .to.have.deep.nested.property("[0]") + .that.is.an.instanceof(Element); + expect(existingTag).to.have.property("getAttribute"); + expect(existingTag.getAttribute("name")).to.equal( + "description" + ); + expect(existingTag.getAttribute("content")).to.equal( + 'This is "quoted" text and & and \'.' + ); + expect(existingTag.outerHTML).to.equal( + `` + ); + + done(); + }); + }); + + it("does not change the DOM if it recevies identical props", done => { + const store = createHelmetStore(); + const spy = sinon.spy(); + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + // Re-rendering will pass new props to an already mounted Helmet + ReactDOM.render( + + + , + container + ); + + requestAnimationFrame(() => { + expect(spy.callCount).to.equal(1); + + done(); + }); + }); + }); + + it("does not write the DOM if the client and server are identical", done => { + const store = createHelmetStore(); + headElement.innerHTML = `