diff --git a/README.md b/README.md index 0d5d7138..552b42cf 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,31 @@ Will result in: A list of prioritized tags and attributes can be found in [constants.js](./src/constants.js). +## Usage without Context +You can optionally use `` outside a context by manually creating a stateful `HelmetData` instance, and passing that stateful object to each `` instance: + + +```js +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { Helmet, HelmetProvider, HelmetData } from 'react-helmet-async'; + +const helmetData = new HelmetData({}); + +const app = ( + + + Hello World + + +

Hello World

+
+); + +const html = renderToString(app); + +const { helmet } = helmetData.context; +``` ## License diff --git a/__tests__/server/__snapshots__/helmetData.test.js.snap b/__tests__/server/__snapshots__/helmetData.test.js.snap new file mode 100644 index 00000000..14ccffdc --- /dev/null +++ b/__tests__/server/__snapshots__/helmetData.test.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Helmet Data browser renders declarative without context 1`] = `""`; + +exports[`Helmet Data browser renders without context 1`] = `""`; + +exports[`Helmet Data browser sets base tag based on deepest nested component 1`] = `""`; + +exports[`Helmet Data server renders declarative without context 1`] = `""`; + +exports[`Helmet Data server renders without context 1`] = `""`; + +exports[`Helmet Data server sets base tag based on deepest nested component 1`] = `""`; diff --git a/__tests__/server/helmetData.test.js b/__tests__/server/helmetData.test.js new file mode 100644 index 00000000..ba79ed2c --- /dev/null +++ b/__tests__/server/helmetData.test.js @@ -0,0 +1,145 @@ +import React, { StrictMode } from 'react'; +import ReactDOM from 'react-dom'; +import { Helmet } from '../../src'; +import Provider from '../../src/Provider'; +import HelmetData from '../../src/HelmetData'; +import { HELMET_ATTRIBUTE } from '../../src/constants'; + +Helmet.defaultProps.defer = false; + +const render = node => { + const mount = document.getElementById('mount'); + + ReactDOM.render({node}, mount); +}; + +describe('Helmet Data', () => { + describe('server', () => { + beforeAll(() => { + Provider.canUseDOM = false; + }); + + afterAll(() => { + Provider.canUseDOM = true; + }); + + it('renders without context', () => { + const helmetData = new HelmetData({}); + + render( + + ); + + const head = helmetData.context.helmet; + + expect(head.base).toBeDefined(); + expect(head.base.toString).toBeDefined(); + expect(head.base.toString()).toMatchSnapshot(); + }); + + it('renders declarative without context', () => { + const helmetData = new HelmetData({}); + + render( + + + + ); + + const head = helmetData.context.helmet; + + expect(head.base).toBeDefined(); + expect(head.base.toString).toBeDefined(); + expect(head.base.toString()).toMatchSnapshot(); + }); + + it('sets base tag based on deepest nested component', () => { + const helmetData = new HelmetData({}); + + render( +
+ + + + + + +
+ ); + + const head = helmetData.context.helmet; + + expect(head.base).toBeDefined(); + expect(head.base.toString).toBeDefined(); + expect(head.base.toString()).toMatchSnapshot(); + }); + }); + + describe('browser', () => { + it('renders without context', () => { + const helmetData = new HelmetData({}); + + render( + + ); + + const existingTags = document.head.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); + const firstTag = [].slice.call(existingTags)[0]; + + expect(existingTags).toBeDefined(); + expect(existingTags).toHaveLength(1); + + expect(firstTag).toBeInstanceOf(Element); + expect(firstTag.getAttribute).toBeDefined(); + expect(firstTag.getAttribute('href')).toEqual('http://localhost/'); + expect(firstTag.outerHTML).toMatchSnapshot(); + }); + + it('renders declarative without context', () => { + const helmetData = new HelmetData({}); + + render( + + + + ); + + const existingTags = document.head.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); + const firstTag = [].slice.call(existingTags)[0]; + + expect(existingTags).toBeDefined(); + expect(existingTags).toHaveLength(1); + + expect(firstTag).toBeInstanceOf(Element); + expect(firstTag.getAttribute).toBeDefined(); + expect(firstTag.getAttribute('href')).toEqual('http://localhost/'); + expect(firstTag.outerHTML).toMatchSnapshot(); + }); + + it('sets base tag based on deepest nested component', () => { + const helmetData = new HelmetData({}); + + render( +
+ + + + + + +
+ ); + + const existingTags = document.head.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); + const firstTag = [].slice.call(existingTags)[0]; + + expect(existingTags).toBeDefined(); + expect(existingTags).toHaveLength(1); + + expect(firstTag).toBeInstanceOf(Element); + expect(firstTag.getAttribute).toBeDefined(); + expect(firstTag.getAttribute('href')).toEqual('http://mysite.com/public'); + expect(firstTag.outerHTML).toMatchSnapshot(); + }); + }); +}); diff --git a/src/HelmetData.js b/src/HelmetData.js new file mode 100644 index 00000000..ff353892 --- /dev/null +++ b/src/HelmetData.js @@ -0,0 +1,41 @@ +import mapStateOnServer from './server'; + +export default class HelmetData { + instances = []; + + value = { + setHelmet: serverState => { + this.context.helmet = serverState; + }, + helmetInstances: { + get: () => this.instances, + add: instance => { + this.instances.push(instance); + }, + remove: instance => { + const index = this.instances.indexOf(instance); + this.instances.splice(index, 1); + }, + }, + }; + + constructor(context) { + this.context = context; + + if (!HelmetData.canUseDOM) { + context.helmet = mapStateOnServer({ + baseTag: [], + bodyAttributes: {}, + encodeSpecialCharacters: true, + htmlAttributes: {}, + linkTags: [], + metaTags: [], + noscriptTags: [], + scriptTags: [], + styleTags: [], + title: '', + titleAttributes: {}, + }); + } + } +} diff --git a/src/Provider.js b/src/Provider.js index c668b561..36432877 100644 --- a/src/Provider.js +++ b/src/Provider.js @@ -1,6 +1,6 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import mapStateOnServer from './server'; +import HelmetData from './HelmetData'; const defaultValue = {}; @@ -33,45 +33,13 @@ export default class Provider extends Component { static displayName = 'HelmetProvider'; - instances = []; - - value = { - setHelmet: serverState => { - this.props.context.helmet = serverState; - }, - helmetInstances: { - get: () => this.instances, - add: instance => { - this.instances.push(instance); - }, - remove: instance => { - const index = this.instances.indexOf(instance); - this.instances.splice(index, 1); - }, - }, - }; - constructor(props) { super(props); - if (!Provider.canUseDOM) { - props.context.helmet = mapStateOnServer({ - baseTag: [], - bodyAttributes: {}, - encodeSpecialCharacters: true, - htmlAttributes: {}, - linkTags: [], - metaTags: [], - noscriptTags: [], - scriptTags: [], - styleTags: [], - title: '', - titleAttributes: {}, - }); - } + this.helmetData = new HelmetData(this.props.context); } render() { - return {this.props.children}; + return {this.props.children}; } } diff --git a/src/index.js b/src/index.js index dbc75ff6..cb51ce2b 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ import { Context } from './Provider'; import Dispatcher from './Dispatcher'; import { TAG_NAMES, VALID_TAG_NAMES, HTML_TAG_MAP } from './constants'; +export { default as HelmetData } from './HelmetData'; export { default as HelmetProvider } from './Provider'; /* eslint-disable class-methods-use-this */ @@ -48,6 +49,7 @@ export class Helmet extends Component { titleAttributes: PropTypes.object, titleTemplate: PropTypes.string, prioritizeSeoTags: PropTypes.bool, + helmetData: PropTypes.object, }; /* eslint-enable react/prop-types, react/forbid-prop-types, react/require-default-props */ @@ -218,14 +220,17 @@ export class Helmet extends Component { } render() { - const { children, ...props } = this.props; + const { children, helmetData, ...props } = this.props; let newProps = { ...props }; if (children) { newProps = this.mapChildrenToProps(children, newProps); } - return ( + return helmetData ? ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ) : ( {( context // eslint-disable-next-line react/jsx-props-no-spreading