Skip to content

Commit 3272c9a

Browse files
blittlestaylor
authored andcommitted
feat: add the ability to use <Helmet> without context
This is useful for React Server Components, which do not yet support context
1 parent 735a0d4 commit 3272c9a

File tree

6 files changed

+234
-37
lines changed

6 files changed

+234
-37
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,31 @@ Will result in:
173173

174174
A list of prioritized tags and attributes can be found in [constants.js](./src/constants.js).
175175

176+
## Usage without Context
177+
You can optionally use `<Helmet>` outside a context by manually creating a stateful `HelmetData` instance, and passing that stateful object to each `<Helmet>` instance:
178+
179+
180+
```js
181+
import React from 'react';
182+
import { renderToString } from 'react-dom/server';
183+
import { Helmet, HelmetProvider, HelmetData } from 'react-helmet-async';
184+
185+
const helmetData = new HelmetData({});
186+
187+
const app = (
188+
<App>
189+
<Helmet helmetData={helmetData}>
190+
<title>Hello World</title>
191+
<link rel="canonical" href="https://www.tacobell.com/" />
192+
</Helmet>
193+
<h1>Hello World</h1>
194+
</App>
195+
);
196+
197+
const html = renderToString(app);
198+
199+
const { helmet } = helmetData.context;
200+
```
176201

177202
## License
178203

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`Helmet Data browser renders declarative without context 1`] = `"<base target=\\"_blank\\" href=\\"http://localhost/\\" data-rh=\\"true\\">"`;
4+
5+
exports[`Helmet Data browser renders without context 1`] = `"<base target=\\"_blank\\" href=\\"http://localhost/\\" data-rh=\\"true\\">"`;
6+
7+
exports[`Helmet Data browser sets base tag based on deepest nested component 1`] = `"<base href=\\"http://mysite.com/public\\" data-rh=\\"true\\">"`;
8+
9+
exports[`Helmet Data server renders declarative without context 1`] = `"<base data-rh=\\"true\\" target=\\"_blank\\" href=\\"http://localhost/\\"/>"`;
10+
11+
exports[`Helmet Data server renders without context 1`] = `"<base data-rh=\\"true\\" target=\\"_blank\\" href=\\"http://localhost/\\"/>"`;
12+
13+
exports[`Helmet Data server sets base tag based on deepest nested component 1`] = `"<base data-rh=\\"true\\" href=\\"http://mysite.com/public\\"/>"`;

__tests__/server/helmetData.test.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import React, { StrictMode } from 'react';
2+
import ReactDOM from 'react-dom';
3+
import { Helmet } from '../../src';
4+
import Provider from '../../src/Provider';
5+
import HelmetData from '../../src/HelmetData';
6+
import { HELMET_ATTRIBUTE } from '../../src/constants';
7+
8+
Helmet.defaultProps.defer = false;
9+
10+
const render = node => {
11+
const mount = document.getElementById('mount');
12+
13+
ReactDOM.render(<StrictMode>{node}</StrictMode>, mount);
14+
};
15+
16+
describe('Helmet Data', () => {
17+
describe('server', () => {
18+
beforeAll(() => {
19+
Provider.canUseDOM = false;
20+
});
21+
22+
afterAll(() => {
23+
Provider.canUseDOM = true;
24+
});
25+
26+
it('renders without context', () => {
27+
const helmetData = new HelmetData({});
28+
29+
render(
30+
<Helmet helmetData={helmetData} base={{ target: '_blank', href: 'http://localhost/' }} />
31+
);
32+
33+
const head = helmetData.context.helmet;
34+
35+
expect(head.base).toBeDefined();
36+
expect(head.base.toString).toBeDefined();
37+
expect(head.base.toString()).toMatchSnapshot();
38+
});
39+
40+
it('renders declarative without context', () => {
41+
const helmetData = new HelmetData({});
42+
43+
render(
44+
<Helmet helmetData={helmetData}>
45+
<base target="_blank" href="http://localhost/" />
46+
</Helmet>
47+
);
48+
49+
const head = helmetData.context.helmet;
50+
51+
expect(head.base).toBeDefined();
52+
expect(head.base.toString).toBeDefined();
53+
expect(head.base.toString()).toMatchSnapshot();
54+
});
55+
56+
it('sets base tag based on deepest nested component', () => {
57+
const helmetData = new HelmetData({});
58+
59+
render(
60+
<div>
61+
<Helmet helmetData={helmetData}>
62+
<base href="http://mysite.com" />
63+
</Helmet>
64+
<Helmet helmetData={helmetData}>
65+
<base href="http://mysite.com/public" />
66+
</Helmet>
67+
</div>
68+
);
69+
70+
const head = helmetData.context.helmet;
71+
72+
expect(head.base).toBeDefined();
73+
expect(head.base.toString).toBeDefined();
74+
expect(head.base.toString()).toMatchSnapshot();
75+
});
76+
});
77+
78+
describe('browser', () => {
79+
it('renders without context', () => {
80+
const helmetData = new HelmetData({});
81+
82+
render(
83+
<Helmet helmetData={helmetData} base={{ target: '_blank', href: 'http://localhost/' }} />
84+
);
85+
86+
const existingTags = document.head.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`);
87+
const firstTag = [].slice.call(existingTags)[0];
88+
89+
expect(existingTags).toBeDefined();
90+
expect(existingTags).toHaveLength(1);
91+
92+
expect(firstTag).toBeInstanceOf(Element);
93+
expect(firstTag.getAttribute).toBeDefined();
94+
expect(firstTag.getAttribute('href')).toEqual('http://localhost/');
95+
expect(firstTag.outerHTML).toMatchSnapshot();
96+
});
97+
98+
it('renders declarative without context', () => {
99+
const helmetData = new HelmetData({});
100+
101+
render(
102+
<Helmet helmetData={helmetData}>
103+
<base target="_blank" href="http://localhost/" />
104+
</Helmet>
105+
);
106+
107+
const existingTags = document.head.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`);
108+
const firstTag = [].slice.call(existingTags)[0];
109+
110+
expect(existingTags).toBeDefined();
111+
expect(existingTags).toHaveLength(1);
112+
113+
expect(firstTag).toBeInstanceOf(Element);
114+
expect(firstTag.getAttribute).toBeDefined();
115+
expect(firstTag.getAttribute('href')).toEqual('http://localhost/');
116+
expect(firstTag.outerHTML).toMatchSnapshot();
117+
});
118+
119+
it('sets base tag based on deepest nested component', () => {
120+
const helmetData = new HelmetData({});
121+
122+
render(
123+
<div>
124+
<Helmet helmetData={helmetData}>
125+
<base href="http://mysite.com" />
126+
</Helmet>
127+
<Helmet helmetData={helmetData}>
128+
<base href="http://mysite.com/public" />
129+
</Helmet>
130+
</div>
131+
);
132+
133+
const existingTags = document.head.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`);
134+
const firstTag = [].slice.call(existingTags)[0];
135+
136+
expect(existingTags).toBeDefined();
137+
expect(existingTags).toHaveLength(1);
138+
139+
expect(firstTag).toBeInstanceOf(Element);
140+
expect(firstTag.getAttribute).toBeDefined();
141+
expect(firstTag.getAttribute('href')).toEqual('http://mysite.com/public');
142+
expect(firstTag.outerHTML).toMatchSnapshot();
143+
});
144+
});
145+
});

src/HelmetData.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import mapStateOnServer from './server';
2+
3+
export default class HelmetData {
4+
instances = [];
5+
6+
value = {
7+
setHelmet: serverState => {
8+
this.context.helmet = serverState;
9+
},
10+
helmetInstances: {
11+
get: () => this.instances,
12+
add: instance => {
13+
this.instances.push(instance);
14+
},
15+
remove: instance => {
16+
const index = this.instances.indexOf(instance);
17+
this.instances.splice(index, 1);
18+
},
19+
},
20+
};
21+
22+
constructor(context) {
23+
this.context = context;
24+
25+
if (!HelmetData.canUseDOM) {
26+
context.helmet = mapStateOnServer({
27+
baseTag: [],
28+
bodyAttributes: {},
29+
encodeSpecialCharacters: true,
30+
htmlAttributes: {},
31+
linkTags: [],
32+
metaTags: [],
33+
noscriptTags: [],
34+
scriptTags: [],
35+
styleTags: [],
36+
title: '',
37+
titleAttributes: {},
38+
});
39+
}
40+
}
41+
}

src/Provider.js

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { Component } from 'react';
22
import PropTypes from 'prop-types';
3-
import mapStateOnServer from './server';
3+
import HelmetData from './HelmetData';
44

55
const defaultValue = {};
66

@@ -33,45 +33,13 @@ export default class Provider extends Component {
3333

3434
static displayName = 'HelmetProvider';
3535

36-
instances = [];
37-
38-
value = {
39-
setHelmet: serverState => {
40-
this.props.context.helmet = serverState;
41-
},
42-
helmetInstances: {
43-
get: () => this.instances,
44-
add: instance => {
45-
this.instances.push(instance);
46-
},
47-
remove: instance => {
48-
const index = this.instances.indexOf(instance);
49-
this.instances.splice(index, 1);
50-
},
51-
},
52-
};
53-
5436
constructor(props) {
5537
super(props);
5638

57-
if (!Provider.canUseDOM) {
58-
props.context.helmet = mapStateOnServer({
59-
baseTag: [],
60-
bodyAttributes: {},
61-
encodeSpecialCharacters: true,
62-
htmlAttributes: {},
63-
linkTags: [],
64-
metaTags: [],
65-
noscriptTags: [],
66-
scriptTags: [],
67-
styleTags: [],
68-
title: '',
69-
titleAttributes: {},
70-
});
71-
}
39+
this.helmetData = new HelmetData(this.props.context);
7240
}
7341

7442
render() {
75-
return <Context.Provider value={this.value}>{this.props.children}</Context.Provider>;
43+
return <Context.Provider value={this.helmetData.value}>{this.props.children}</Context.Provider>;
7644
}
7745
}

src/index.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Context } from './Provider';
66
import Dispatcher from './Dispatcher';
77
import { TAG_NAMES, VALID_TAG_NAMES, HTML_TAG_MAP } from './constants';
88

9+
export { default as HelmetData } from './HelmetData';
910
export { default as HelmetProvider } from './Provider';
1011

1112
/* eslint-disable class-methods-use-this */
@@ -48,6 +49,7 @@ export class Helmet extends Component {
4849
titleAttributes: PropTypes.object,
4950
titleTemplate: PropTypes.string,
5051
prioritizeSeoTags: PropTypes.bool,
52+
helmetData: PropTypes.object,
5153
};
5254
/* eslint-enable react/prop-types, react/forbid-prop-types, react/require-default-props */
5355

@@ -218,14 +220,17 @@ export class Helmet extends Component {
218220
}
219221

220222
render() {
221-
const { children, ...props } = this.props;
223+
const { children, helmetData, ...props } = this.props;
222224
let newProps = { ...props };
223225

224226
if (children) {
225227
newProps = this.mapChildrenToProps(children, newProps);
226228
}
227229

228-
return (
230+
return helmetData ? (
231+
// eslint-disable-next-line react/jsx-props-no-spreading
232+
<Dispatcher {...newProps} context={helmetData.value} />
233+
) : (
229234
<Context.Consumer>
230235
{(
231236
context // eslint-disable-next-line react/jsx-props-no-spreading

0 commit comments

Comments
 (0)