Skip to content

Commit

Permalink
feat: add the ability to use <Helmet> without context
Browse files Browse the repository at this point in the history
This is useful for React Server Components, which do not yet support context
  • Loading branch information
blittle authored and staylor committed Dec 1, 2021
1 parent 735a0d4 commit 3272c9a
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 37 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Helmet>` outside a context by manually creating a stateful `HelmetData` instance, and passing that stateful object to each `<Helmet>` 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 = (
<App>
<Helmet helmetData={helmetData}>
<title>Hello World</title>
<link rel="canonical" href="https://www.tacobell.com/" />
</Helmet>
<h1>Hello World</h1>
</App>
);

const html = renderToString(app);

const { helmet } = helmetData.context;
```

## License

Expand Down
13 changes: 13 additions & 0 deletions __tests__/server/__snapshots__/helmetData.test.js.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Helmet Data browser renders declarative without context 1`] = `"<base target=\\"_blank\\" href=\\"http://localhost/\\" data-rh=\\"true\\">"`;
exports[`Helmet Data browser renders without context 1`] = `"<base target=\\"_blank\\" href=\\"http://localhost/\\" data-rh=\\"true\\">"`;
exports[`Helmet Data browser sets base tag based on deepest nested component 1`] = `"<base href=\\"http://mysite.com/public\\" data-rh=\\"true\\">"`;
exports[`Helmet Data server renders declarative without context 1`] = `"<base data-rh=\\"true\\" target=\\"_blank\\" href=\\"http://localhost/\\"/>"`;
exports[`Helmet Data server renders without context 1`] = `"<base data-rh=\\"true\\" target=\\"_blank\\" href=\\"http://localhost/\\"/>"`;
exports[`Helmet Data server sets base tag based on deepest nested component 1`] = `"<base data-rh=\\"true\\" href=\\"http://mysite.com/public\\"/>"`;
145 changes: 145 additions & 0 deletions __tests__/server/helmetData.test.js
Original file line number Diff line number Diff line change
@@ -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(<StrictMode>{node}</StrictMode>, mount);
};

describe('Helmet Data', () => {
describe('server', () => {
beforeAll(() => {
Provider.canUseDOM = false;
});

afterAll(() => {
Provider.canUseDOM = true;
});

it('renders without context', () => {
const helmetData = new HelmetData({});

render(
<Helmet helmetData={helmetData} base={{ target: '_blank', href: 'http://localhost/' }} />
);

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(
<Helmet helmetData={helmetData}>
<base target="_blank" href="http://localhost/" />
</Helmet>
);

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(
<div>
<Helmet helmetData={helmetData}>
<base href="http://mysite.com" />
</Helmet>
<Helmet helmetData={helmetData}>
<base href="http://mysite.com/public" />
</Helmet>
</div>
);

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(
<Helmet helmetData={helmetData} base={{ target: '_blank', href: 'http://localhost/' }} />
);

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(
<Helmet helmetData={helmetData}>
<base target="_blank" href="http://localhost/" />
</Helmet>
);

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(
<div>
<Helmet helmetData={helmetData}>
<base href="http://mysite.com" />
</Helmet>
<Helmet helmetData={helmetData}>
<base href="http://mysite.com/public" />
</Helmet>
</div>
);

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();
});
});
});
41 changes: 41 additions & 0 deletions src/HelmetData.js
Original file line number Diff line number Diff line change
@@ -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: {},
});
}
}
}
38 changes: 3 additions & 35 deletions src/Provider.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import mapStateOnServer from './server';
import HelmetData from './HelmetData';

const defaultValue = {};

Expand Down Expand Up @@ -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 <Context.Provider value={this.value}>{this.props.children}</Context.Provider>;
return <Context.Provider value={this.helmetData.value}>{this.props.children}</Context.Provider>;
}
}
9 changes: 7 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 */

Expand Down Expand Up @@ -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
<Dispatcher {...newProps} context={helmetData.value} />
) : (
<Context.Consumer>
{(
context // eslint-disable-next-line react/jsx-props-no-spreading
Expand Down

0 comments on commit 3272c9a

Please sign in to comment.