├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .flowconfig ├── .gitignore ├── .prettierignore ├── .size-snapshot.json ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── example ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── app.html │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt └── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── Contact.css │ ├── Contact.js │ ├── Footer.css │ ├── Footer.js │ ├── Home.css │ ├── Home.js │ ├── client.js │ ├── client │ ├── index.css │ └── index.js │ ├── index.js │ ├── logo.svg │ └── server │ ├── index.js │ └── middleware │ ├── html.js │ └── render.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── HeadProvider.js ├── HeadTag.js ├── context.js ├── index.d.ts ├── index.js ├── index.js.flow └── test_flow.js └── tests ├── ReactDOMMock.js ├── __snapshots__ ├── dom.test.js.snap ├── ssr.test.js.snap └── web.test.js.snap ├── dom.test.js ├── ssr.test.js └── web.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tabs 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{json,yml,md,babelrc,eslintrc,remarkrc}] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | test_flow.js 2 | package.json 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'babel-eslint', 3 | plugins: ['prettier'], 4 | extends: ['airbnb', 'prettier', 'prettier/react'], 5 | env: { 6 | browser: true, 7 | jest: true, 8 | }, 9 | rules: { 10 | 'global-require': 0, 11 | 'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx'] }], 12 | 'react/forbid-prop-types': 0, 13 | 'react/prop-types': 0, 14 | 'react/no-did-mount-set-state': 0, 15 | 'react/sort-comp': 0, 16 | 'react/no-unused-state': 0, 17 | 'react/require-default-props': 0, 18 | 'react/state-in-constructor': [0, 'never'], 19 | 'react/jsx-props-no-spreading': 0, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | 11 | [strict] 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | dist 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (http://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # Typescript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package*.json 2 | -------------------------------------------------------------------------------- /.size-snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "index.umd.js": { 3 | "bundled": 8382, 4 | "minified": 3575, 5 | "gzipped": 1446 6 | }, 7 | "index.min.js": { 8 | "bundled": 8382, 9 | "minified": 3575, 10 | "gzipped": 1446 11 | }, 12 | "index.cjs.js": { 13 | "bundled": 7477, 14 | "minified": 4049, 15 | "gzipped": 1337 16 | }, 17 | "index.esm.js": { 18 | "bundled": 6593, 19 | "minified": 3316, 20 | "gzipped": 1188, 21 | "treeshaked": { 22 | "rollup": { 23 | "code": 305, 24 | "import_statements": 269 25 | }, 26 | "webpack": { 27 | "code": 1549 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | - 12 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First, thank you for contributing! 🎉 4 | 5 | The local development workflow is fairly straightfowrad: 6 | 7 | 1. Fork the repo and then git clone your fork locally (be sure to work on a new branch, not on your `master` branch) 8 | 1. `npm install` 9 | 1. `npm run dev` this will start the source build watcher and install/start the example app for you 10 | 1. Then you can view the example app running at http://localhost:3000. Changes you make to the source will compile and hot-reload 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Jeremy Gayed 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-head [![npm Version](https://img.shields.io/npm/v/react-head.svg?style=flat-square)](https://www.npmjs.org/package/react-head) [![bundlephobia](https://badgen.net/bundlephobia/minzip/react-head)](https://bundlephobia.com/result?p=react-head) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](CONTRIBUTING.md#pull-requests) 2 | 3 | Asynchronous SSR-ready Document Head management for React 16.3+ 4 | 5 | ## Motivation 6 | 7 | This module allows you to define `document.head` tags anywhere in your component hierarchy. The motivations are similar to [react-helmet](https://github.com/nfl/react-helmet) in that you may only have the information for certain tags contextually deep in your component hiearchy. There are no dependencies (it does not use react-side-effects) and it should work fine with asynchronous rendering; the only requirement is React 16.3+. 8 | 9 | [Read more about react-head and how it works on Medium](https://jeremygayed.com/making-head-tag-management-thread-safe-with-react-head-323654170b45) 10 | 11 | ## Installation 12 | 13 | ```sh 14 | npm i react-head 15 | ``` 16 | 17 | or 18 | 19 | ```sh 20 | yarn add react-head 21 | ``` 22 | 23 | ## How it works 24 | 25 | 1. You wrap your App with `` 26 | 1. From the server, you pass `headTags[]` array to `` 27 | 1. Then call `renderToStaticMarkup(headTags)` and include in the `` block of your server template 28 | 1. To insert head tags within your app, just render one of ``, `<Meta />`, `<Style />`, `<Link />`, and `<Base />` components as often as needed. 29 | 30 | On the server, the tags are collected in the `headTags[]` array, and then on the client the server-generated tags are removed in favor of the client-rendered tags so that SPAs still work as expected (e.g. in cases where subsequent page loads need to change the head tags). 31 | 32 | > You can view a fully working sample app in the [/example](/example) folder. 33 | 34 | ### Server setup 35 | 36 | Wrap your app with `<HeadProvider />` on the server, using a `headTags[]` array to pass down as part of your server-rendered payload. When rendered, the component mutates this array to contain the tags. 37 | 38 | ```js 39 | import * as React from 'react'; 40 | import { renderToString } from 'react-dom/server'; 41 | import { HeadProvider } from 'react-head'; 42 | import App from './App'; 43 | 44 | // ... within the context of a request ... 45 | 46 | const headTags = []; // mutated during render so you can include in server-rendered template later 47 | const app = renderToString( 48 | <HeadProvider headTags={headTags}> 49 | <App /> 50 | </HeadProvider> 51 | ); 52 | 53 | res.send(` 54 | <!doctype html> 55 | <head> 56 | ${renderToString(headTags)} 57 | </head> 58 | <body> 59 | <div id="root">${app}</div> 60 | </body> 61 | </html> 62 | `); 63 | ``` 64 | 65 | ### Client setup 66 | 67 | There is nothing special required on the client, just render one of head tag components whenever you want to inject a tag in the `<head />`. 68 | 69 | ```js 70 | import * as React from 'react'; 71 | import { HeadProvider, Title, Link, Meta } from 'react-head'; 72 | 73 | const App = () => ( 74 | <HeadProvider> 75 | <div className="Home"> 76 | <Title>Title of page 77 | 78 | 79 | // ... 80 | 81 | 82 | ); 83 | ``` 84 | 85 | ## Contributing 86 | 87 | Please follow the [contributing docs](/CONTRIBUTING.md) 88 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@babel/env', { loose: true }], '@babel/react'], 3 | plugins: [['@babel/proposal-class-properties', { loose: true }]], 4 | }; 5 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # development 12 | /dist 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # react-head example app 2 | 3 | Example app showing [react-head](https://github.com/tizmagik/react-head) usage. Bootstrapped with [create-react-ssr-app](https://create-react-ssr-app.dev/). 4 | 5 | * Check out [server setup example](/example/src/server/middleware/render.js). 6 | * Check out [client setup example](/example/src/client.js). 7 | * Check out [Home.js](/example/src/Home.js) and [Contact.js](/example/src/Contact.js) for app usage example. 8 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-head-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "escape-string-regexp": "^4.0.0", 7 | "express": "^4.17.1", 8 | "react": "^16.13.1", 9 | "react-dom": "^16.13.1", 10 | "react-head": "file://../", 11 | "react-router-dom": "^5.2.0", 12 | "react-ssr-scripts": "2.2.3" 13 | }, 14 | "scripts": { 15 | "start": "react-ssr-scripts start", 16 | "build": "react-ssr-scripts build", 17 | "test": "react-ssr-scripts test", 18 | "eject": "react-ssr-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": "react-app" 22 | }, 23 | "browserslist": { 24 | "production": [ 25 | ">0.2%", 26 | "not dead", 27 | "not op_mini all" 28 | ], 29 | "development": [ 30 | "last 1 chrome version", 31 | "last 1 firefox version", 32 | "last 1 safari version" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /example/public/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 21 | __REACT_HEAD_CONTENT__ 22 | 23 | 24 | 25 | 26 |
__HTML_CONTENT__
27 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tizmagik/react-head/5c69cdb54703a373d1d3a2fb33415a2a13025b7b/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tizmagik/react-head/5c69cdb54703a373d1d3a2fb33415a2a13025b7b/example/public/logo192.png -------------------------------------------------------------------------------- /example/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tizmagik/react-head/5c69cdb54703a373d1d3a2fb33415a2a13025b7b/example/public/logo512.png -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "react-head example app", 3 | "name": "react-head example app", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /example/src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } -------------------------------------------------------------------------------- /example/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Switch } from 'react-router-dom'; 3 | import Home from './Home'; 4 | import Contact from './Contact'; 5 | import './App.css'; 6 | 7 | const App = () => ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /example/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import { HeadProvider } from 'react-head'; 5 | import App from './App'; 6 | 7 | describe('', () => { 8 | test('renders without exploding', () => { 9 | const div = document.createElement('div'); 10 | 11 | ReactDOM.render( 12 | 13 | 14 | 15 | 16 | , 17 | div 18 | ); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /example/src/Contact.css: -------------------------------------------------------------------------------- 1 | .Contact { 2 | text-align: center; 3 | } 4 | 5 | .Contact-logo { 6 | animation: Contact-logo-spin infinite 20s linear; 7 | height: 80px; 8 | } 9 | 10 | .Contact-header { 11 | background-color: #222; 12 | height: 150px; 13 | padding: 20px; 14 | color: white; 15 | } 16 | 17 | .Contact-intro { 18 | font-size: large; 19 | } 20 | 21 | @keyframes Contact-logo-spin { 22 | from { transform: rotate(0deg); } 23 | to { transform: rotate(360deg); } 24 | } 25 | -------------------------------------------------------------------------------- /example/src/Contact.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/anchor-is-valid */ 2 | import React from 'react'; 3 | import { Link, Title, Meta } from 'react-head'; 4 | import { Link as RouterLink } from 'react-router-dom'; 5 | import Footer from './Footer'; 6 | import logo from './logo.svg'; 7 | import './Contact.css'; 8 | 9 | const NestedComponent = () => ( 10 | Contact | Example react-head App (with cascading title) 11 | ); 12 | 13 | const Contact = () => ( 14 |
15 | Contact | Example react-head App 16 | 17 | 18 |
19 | logo 20 |

react-head contact us page

21 |
22 | 23 |

24 | View the example code in src/Contact.js. Note that this works 25 | isomorphically. 26 |

27 |

28 | Click the example home page below to see how the Header tags will update 29 |

30 | Home 31 |
32 |
33 | ); 34 | 35 | export default Contact; 36 | -------------------------------------------------------------------------------- /example/src/Footer.css: -------------------------------------------------------------------------------- 1 | footer { 2 | border-top: 1px solid #efefef; 3 | width: 100%; 4 | position: absolute; 5 | bottom: 0px; 6 | margin: 0 auto; 7 | } 8 | 9 | footer ul { 10 | list-style: none; 11 | padding-left: 0; 12 | } 13 | 14 | footer li { 15 | display: inline-block; 16 | padding: 1rem; 17 | } -------------------------------------------------------------------------------- /example/src/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Footer.css'; 3 | 4 | const Footer = () => ( 5 |
6 |
    7 |
  • 8 | 9 | ↗️ 10 | {' '} 11 | Docs 12 |
  • 13 |
  • 14 | 15 | ↗️ 16 | {' '} 17 | Issues 18 |
  • 19 |
20 |
21 | ); 22 | 23 | export default Footer; 24 | -------------------------------------------------------------------------------- /example/src/Home.css: -------------------------------------------------------------------------------- 1 | .Home { 2 | text-align: center; 3 | } 4 | 5 | .Home-logo { 6 | animation: Home-logo-spin infinite 20s linear; 7 | height: 80px; 8 | } 9 | 10 | .Home-header { 11 | background-color: #222; 12 | height: 150px; 13 | padding: 20px; 14 | color: white; 15 | } 16 | 17 | .Home-intro { 18 | font-size: large; 19 | } 20 | 21 | @keyframes Home-logo-spin { 22 | from { transform: rotate(0deg); } 23 | to { transform: rotate(360deg); } 24 | } 25 | -------------------------------------------------------------------------------- /example/src/Home.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/anchor-is-valid */ 2 | import React from 'react'; 3 | import { Link, Meta, Title, Style, Base } from 'react-head'; 4 | import { Link as RouterLink } from 'react-router-dom'; 5 | import Footer from './Footer'; 6 | import logo from './logo.svg'; 7 | import './Home.css'; 8 | 9 | const NestedComponent = () => ( 10 | 11 | ); 12 | 13 | const Home = () => ( 14 |
15 | Home | Example react-head App 16 | 19 | 20 | 21 | 22 |
23 | logo 24 |

react-head example

25 |
26 |

27 | View the example code in src/Home.js. Note that this works 28 | isomorphically. 29 | 30 |

31 |

32 | Click the example contact page below to see how the Header tags will 33 | update 34 |

35 | Contact Page 36 |
37 |
38 | ); 39 | 40 | export default Home; 41 | -------------------------------------------------------------------------------- /example/src/client.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { hydrate } from 'react-dom'; 3 | import { HeadProvider } from 'react-head'; 4 | import App from '../App'; 5 | 6 | hydrate( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ); 12 | 13 | if (module.hot) { 14 | module.hot.accept(); 15 | } 16 | -------------------------------------------------------------------------------- /example/src/client/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /example/src/client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import { HeadProvider } from 'react-head'; 5 | 6 | import './index.css'; 7 | import App from '../App'; 8 | 9 | ReactDOM.hydrate( 10 | 11 | 12 | 13 | 14 | , 15 | document.getElementById('root') 16 | ); 17 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | const { PORT = 3000 } = process.env; 4 | 5 | let app = require('./server').default; 6 | 7 | if (module.hot) { 8 | module.hot.accept('./server', () => { 9 | console.log('Server reloading...'); 10 | 11 | try { 12 | app = require('./server').default; 13 | } catch (error) { 14 | // Do nothing 15 | } 16 | }); 17 | } 18 | 19 | express() 20 | .use((req, res) => app.handle(req, res)) 21 | .listen(PORT, () => { 22 | console.log(`React SSR App is running: http://localhost:${PORT}`); 23 | }); 24 | -------------------------------------------------------------------------------- /example/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /example/src/server/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import express from 'express'; 3 | 4 | import htmlMiddleware from './middleware/html'; 5 | import renderMiddleware from './middleware/render'; 6 | 7 | const publicPath = path.join(__dirname, '/public'); 8 | const app = express(); 9 | 10 | app.use(express.static(publicPath)); 11 | app.use(htmlMiddleware()); 12 | app.use(renderMiddleware()); 13 | 14 | export default app; 15 | -------------------------------------------------------------------------------- /example/src/server/middleware/html.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | const htmlMiddleware = () => (req, res, next) => { 5 | const publicPath = path.join(__dirname, '/public'); 6 | 7 | fs.readFile(`${publicPath}/app.html`, 'utf8', (err, html) => { 8 | if (!err) { 9 | req.html = html; 10 | next(); 11 | } else { 12 | res.status(500).send('Error parsing app.html'); 13 | } 14 | }); 15 | }; 16 | 17 | export default htmlMiddleware; 18 | -------------------------------------------------------------------------------- /example/src/server/middleware/render.js: -------------------------------------------------------------------------------- 1 | import escapeStringRegexp from 'escape-string-regexp'; 2 | import React from 'react'; 3 | import { StaticRouter } from 'react-router-dom'; 4 | import { renderToString } from 'react-dom/server'; 5 | import { HeadProvider } from 'react-head'; 6 | import App from '../../App'; 7 | 8 | const renderMiddleware = () => (req, res) => { 9 | let html = req.html; 10 | 11 | const routerContext = {}; 12 | const headTags = []; // mutated during render so you can include in server-rendered template later 13 | const htmlContent = renderToString( 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | 21 | // Here is where we update the server-rendered template 22 | const htmlReplacements = { 23 | HTML_CONTENT: htmlContent, // server-rendered app 24 | REACT_HEAD_CONTENT: renderToString(headTags), // react-head content 25 | }; 26 | Object.keys(htmlReplacements).forEach(key => { 27 | const value = htmlReplacements[key]; 28 | html = html.replace( 29 | new RegExp('__' + escapeStringRegexp(key) + '__', 'g'), 30 | value 31 | ); 32 | }); 33 | 34 | if (routerContext.url) { 35 | res.redirect(302, routerContext.url); 36 | } else { 37 | res.send(html); 38 | } 39 | }; 40 | 41 | export default renderMiddleware; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-head", 3 | "version": "3.4.2", 4 | "description": "SSR-ready Document Head management for React 16+", 5 | "main": "dist/index.cjs.js", 6 | "module": "dist/index.esm.js", 7 | "types": "src/index.d.ts", 8 | "files": [ 9 | "src", 10 | "dist" 11 | ], 12 | "scripts": { 13 | "prepare": "npm run test && npm run build", 14 | "build:flow": "echo \"// @flow\n\nexport * from '../src'\" > dist/index.cjs.js.flow", 15 | "build:watch": "rollup -c --watch", 16 | "build": "rollup -c && npm run build:flow", 17 | "example": "cd example && npm ci && npm run start", 18 | "dev": "run-p build:watch example", 19 | "test": "jest --no-cache", 20 | "posttest": "npm run lint", 21 | "test:watch": "jest --watch", 22 | "typecheck:flow": "flow check --max-warnings=0", 23 | "lint": "eslint ./src", 24 | "prettier": "prettier --write \"src/**/*.js\"", 25 | "precommit": "lint-staged" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/tizmagik/react-head.git" 30 | }, 31 | "keywords": [ 32 | "react", 33 | "head", 34 | "portals", 35 | "ssr", 36 | "isomorphic" 37 | ], 38 | "author": "Jeremy Gayed ", 39 | "contributors": [ 40 | "Bogdan Chadkin " 41 | ], 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/tizmagik/react-head/issues" 45 | }, 46 | "homepage": "https://github.com/tizmagik/react-head#readme", 47 | "devDependencies": { 48 | "@babel/core": "^7.11.6", 49 | "@babel/plugin-proposal-class-properties": "^7.10.4", 50 | "@babel/plugin-transform-runtime": "^7.11.5", 51 | "@babel/preset-env": "^7.11.5", 52 | "@babel/preset-react": "^7.10.4", 53 | "@rollup/plugin-node-resolve": "^9.0.0", 54 | "@rollup/plugin-replace": "^2.3.3", 55 | "babel-eslint": "^10.1.0", 56 | "babel-jest": "^26.5.2", 57 | "eslint": "^7.11.0", 58 | "eslint-config-airbnb": "^18.2.0", 59 | "eslint-config-prettier": "^6.12.0", 60 | "eslint-plugin-import": "^2.22.1", 61 | "eslint-plugin-jsx-a11y": "^6.3.1", 62 | "eslint-plugin-prettier": "^3.1.4", 63 | "eslint-plugin-react": "^7.21.4", 64 | "flow-bin": "^0.135.0", 65 | "husky": "^4.3.0", 66 | "jest": "^26.5.3", 67 | "lint-staged": "^10.4.0", 68 | "npm-run-all": "^4.1.5", 69 | "prettier": "^2.1.2", 70 | "raf": "^3.4.1", 71 | "react": "^16.13.1", 72 | "react-dom": "^16.13.1", 73 | "react-powerplug": "^1.0.0", 74 | "react-test-renderer": "^16.13.1", 75 | "rollup": "^2.29.0", 76 | "rollup-plugin-babel": "^4.4.0", 77 | "rollup-plugin-size-snapshot": "^0.12.0", 78 | "rollup-plugin-terser": "^7.0.2" 79 | }, 80 | "dependencies": { 81 | "@babel/runtime": "^7.11.2" 82 | }, 83 | "peerDependencies": { 84 | "react": ">=16.3", 85 | "react-dom": ">=16.3" 86 | }, 87 | "jest": { 88 | "roots": [ 89 | "./tests" 90 | ], 91 | "setupFiles": [ 92 | "raf/polyfill" 93 | ] 94 | }, 95 | "lint-staged": { 96 | "*.{json,js}": [ 97 | "prettier --write", 98 | "eslint ./src", 99 | "git add" 100 | ] 101 | }, 102 | "prettier": { 103 | "trailingComma": "es5", 104 | "singleQuote": true 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import nodeResolve from '@rollup/plugin-node-resolve'; 3 | import replace from '@rollup/plugin-replace'; 4 | import babel from 'rollup-plugin-babel'; 5 | import { terser } from 'rollup-plugin-terser'; 6 | import { sizeSnapshot } from 'rollup-plugin-size-snapshot'; 7 | import pkg from './package.json'; 8 | 9 | const input = './src/index.js'; 10 | 11 | const name = 'ReactHead'; 12 | 13 | // treat as external everything from node_modules 14 | const external = id => !path.isAbsolute(id) && !id.startsWith('.'); 15 | 16 | const getBabelOptions = ({ useESModules }) => ({ 17 | runtimeHelpers: true, 18 | plugins: [['@babel/transform-runtime', { useESModules }]], 19 | }); 20 | 21 | const globals = { 22 | react: 'React', 23 | 'react-dom': 'ReactDOM', 24 | }; 25 | 26 | export default [ 27 | { 28 | input, 29 | output: { file: 'dist/index.umd.js', format: 'umd', name, globals }, 30 | external: Object.keys(globals), 31 | plugins: [ 32 | nodeResolve(), 33 | babel(getBabelOptions({ useESModules: true })), 34 | replace({ 'process.env.NODE_ENV': JSON.stringify('development') }), 35 | sizeSnapshot(), 36 | ], 37 | }, 38 | 39 | { 40 | input, 41 | output: { file: 'dist/index.min.js', format: 'umd', name, globals }, 42 | external: Object.keys(globals), 43 | plugins: [ 44 | nodeResolve(), 45 | babel(getBabelOptions({ useESModules: true })), 46 | replace({ 'process.env.NODE_ENV': JSON.stringify('production') }), 47 | sizeSnapshot(), 48 | terser(), 49 | ], 50 | }, 51 | 52 | { 53 | input, 54 | output: { file: pkg.main, format: 'cjs', exports: 'named' }, 55 | external, 56 | plugins: [babel(getBabelOptions({ useESModules: false })), sizeSnapshot()], 57 | }, 58 | 59 | { 60 | input, 61 | output: { file: pkg.module, format: 'esm' }, 62 | external, 63 | plugins: [babel(getBabelOptions({ useESModules: true })), sizeSnapshot()], 64 | }, 65 | ]; 66 | -------------------------------------------------------------------------------- /src/HeadProvider.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Provider } from './context'; 3 | 4 | const cascadingTags = ['title', 'meta']; 5 | 6 | export default class HeadProvider extends React.Component { 7 | indices = new Map(); 8 | 9 | state = { 10 | addClientTag: (tag, name) => { 11 | // consider only cascading tags 12 | if (cascadingTags.indexOf(tag) !== -1) { 13 | this.setState(state => { 14 | const names = state[tag] || []; 15 | return { [tag]: [...names, name] }; 16 | }); 17 | // track indices synchronously 18 | const { indices } = this; 19 | const index = indices.has(tag) ? indices.get(tag) + 1 : 0; 20 | indices.set(tag, index); 21 | return index; 22 | } 23 | return -1; 24 | }, 25 | 26 | shouldRenderTag: (tag, index) => { 27 | if (cascadingTags.indexOf(tag) !== -1) { 28 | const names = this.state[tag]; // eslint-disable-line react/destructuring-assignment 29 | // check if the tag is the last one of similar 30 | return names && names.lastIndexOf(names[index]) === index; 31 | } 32 | return true; 33 | }, 34 | 35 | removeClientTag: (tag, index) => { 36 | this.setState(state => { 37 | const names = state[tag]; 38 | if (names) { 39 | names[index] = null; 40 | return { [tag]: names }; 41 | } 42 | return null; 43 | }); 44 | }, 45 | 46 | addServerTag: tagNode => { 47 | const { headTags = [] } = this.props; 48 | // tweak only cascading tags 49 | if (cascadingTags.indexOf(tagNode.type) !== -1) { 50 | const index = headTags.findIndex(prev => { 51 | const prevName = prev.props.name || prev.props.property; 52 | const nextName = tagNode.props.name || tagNode.props.property; 53 | return prev.type === tagNode.type && prevName === nextName; 54 | }); 55 | if (index !== -1) { 56 | headTags.splice(index, 1); 57 | } 58 | } 59 | headTags.push(tagNode); 60 | }, 61 | }; 62 | 63 | componentDidMount() { 64 | const ssrTags = document.head.querySelectorAll(`[data-rh=""]`); 65 | // `forEach` on `NodeList` is not supported in Googlebot, so use a workaround 66 | Array.prototype.forEach.call(ssrTags, ssrTag => 67 | ssrTag.parentNode.removeChild(ssrTag) 68 | ); 69 | } 70 | 71 | render() { 72 | const { headTags, children } = this.props; 73 | 74 | if (typeof window === 'undefined' && Array.isArray(headTags) === false) { 75 | throw Error( 76 | 'headTags array should be passed to in node' 77 | ); 78 | } 79 | 80 | return {children}; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/HeadTag.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { Consumer } from './context'; 4 | 5 | export default class HeadTag extends React.Component { 6 | state = { 7 | canUseDOM: false, 8 | }; 9 | 10 | headTags = null; 11 | 12 | index = -1; 13 | 14 | componentDidMount() { 15 | const { tag, name, property } = this.props; 16 | this.setState({ canUseDOM: true }); 17 | this.index = this.headTags.addClientTag(tag, name || property); 18 | } 19 | 20 | componentWillUnmount() { 21 | const { tag } = this.props; 22 | this.headTags.removeClientTag(tag, this.index); 23 | } 24 | 25 | render() { 26 | const { tag: Tag, ...rest } = this.props; 27 | const { canUseDOM } = this.state; 28 | 29 | return ( 30 | 31 | {headTags => { 32 | if (headTags == null) { 33 | throw Error(' should be in the tree'); 34 | } 35 | 36 | this.headTags = headTags; 37 | 38 | if (canUseDOM) { 39 | if (!headTags.shouldRenderTag(Tag, this.index)) { 40 | return null; 41 | } 42 | const ClientComp = ; 43 | return ReactDOM.createPortal(ClientComp, document.head); 44 | } 45 | 46 | const ServerComp = ; 47 | headTags.addServerTag(ServerComp); 48 | return null; 49 | }} 50 | 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export const { Consumer, Provider } = React.createContext(null); 4 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | /** 4 | * Context based provider for managing head tags 5 | */ 6 | export const HeadProvider: React.ComponentType<{ 7 | headTags?: React.ReactElement[]; 8 | children?: React.ReactNode | undefined 9 | }>; 10 | 11 | /** 12 | * tag component 13 | */ 14 | export const Title: React.ComponentType<React.HTMLAttributes<HTMLTitleElement>>; 15 | 16 | /** 17 | * <style> tag component 18 | */ 19 | export const Style: React.ComponentType< 20 | React.StyleHTMLAttributes<HTMLStyleElement> 21 | >; 22 | 23 | /** 24 | * <meta> tag component 25 | */ 26 | export const Meta: React.ComponentType< 27 | React.MetaHTMLAttributes<HTMLMetaElement> 28 | >; 29 | 30 | /** 31 | * <link> tag component 32 | */ 33 | export const Link: React.ComponentType< 34 | React.LinkHTMLAttributes<HTMLLinkElement> 35 | >; 36 | 37 | /** 38 | * <base> tag component 39 | */ 40 | export const Base: React.ComponentType< 41 | React.BaseHTMLAttributes<HTMLBaseElement> 42 | >; 43 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import HeadTag from './HeadTag'; 3 | 4 | export const Title = props => <HeadTag tag="title" {...props} />; 5 | 6 | export const Style = props => <HeadTag tag="style" {...props} />; 7 | 8 | export const Meta = props => <HeadTag tag="meta" {...props} />; 9 | 10 | export const Link = props => <HeadTag tag="link" {...props} />; 11 | 12 | export const Base = props => <HeadTag tag="base" {...props} />; 13 | 14 | export { default as HeadProvider } from './HeadProvider'; 15 | -------------------------------------------------------------------------------- /src/index.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as React from 'react'; 4 | 5 | declare export var HeadProvider: React.ComponentType<{ 6 | headTags?: Array<React.Element<any>>, 7 | children: React.Node, 8 | }>; 9 | 10 | declare export var Title: React.ComponentType<{ [string]: mixed }>; 11 | 12 | declare export var Style: React.ComponentType<{ [string]: mixed }>; 13 | 14 | declare export var Meta: React.ComponentType<{ [string]: mixed }>; 15 | 16 | declare export var Link: React.ComponentType<{ [string]: mixed }>; 17 | 18 | declare export var Base: React.ComponentType<{ [string]: mixed }>; -------------------------------------------------------------------------------- /src/test_flow.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as React from 'react'; 4 | import { HeadProvider, Title, Style, Meta, Link, Base } from '../'; 5 | 6 | [ 7 | <HeadProvider headTags={[]}> 8 | <div /> 9 | </HeadProvider>, 10 | // $FlowFixMe 11 | <HeadCollector headTags={[]} />, 12 | // $FlowFixMe 13 | <HeadCollector headTags={([]: $ReadOnlyArray<React.Element<>>)}> 14 | <div /> 15 | </HeadCollector>, 16 | // $FlowFixMe 17 | <HeadCollector> 18 | <div /> 19 | </HeadCollector>, 20 | <Title />, 21 | <Style />, 22 | <Meta />, 23 | <Link />, 24 | <Base />, 25 | ]; 26 | -------------------------------------------------------------------------------- /tests/ReactDOMMock.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default jest.mock('react-dom', () => ({ 4 | createPortal: (children, element) => { 5 | if (children == null) { 6 | throw Error('portal children should be provided'); 7 | } 8 | if (element !== document.head) { 9 | throw Error('portal element should be document.head'); 10 | } 11 | const Portal = `portal-${element.tagName.toLowerCase()}`; 12 | return <Portal>{children}</Portal>; 13 | }, 14 | })); 15 | -------------------------------------------------------------------------------- /tests/__snapshots__/dom.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`removes head tags added during ssr 1`] = ` 4 | " 5 | <title data-rh=\\"\\">Test title 6 | 9 | 10 | 11 | " 12 | `; 13 | 14 | exports[`removes head tags added during ssr 2`] = ` 15 | " 16 | 17 | 18 | 19 | 20 | Test title" 21 | `; 22 | -------------------------------------------------------------------------------- /tests/__snapshots__/ssr.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders nothing and adds tags to headTags context array 1`] = `"
Yes render
"`; 4 | 5 | exports[`renders nothing and adds tags to headTags context array 2`] = ` 6 | Array [ 7 | 10 | Title 11 | , 12 | , 17 | , 21 | , 25 | , 29 | ] 30 | `; 31 | 32 | exports[`renders only last meta with the same property 1`] = ` 33 | Array [ 34 | 38 | Meta 2 39 | , 40 | 44 | Meta 4 45 | , 46 | 50 | Meta 6 51 | , 52 | ] 53 | `; 54 | 55 | exports[`renders only the last meta with the same name 1`] = ` 56 | Array [ 57 | 60 | Meta 2 61 | , 62 | 66 | Meta 3 67 | , 68 | 72 | Meta 5 73 | , 74 | 78 | Meta 6 79 | , 80 | ] 81 | `; 82 | 83 | exports[`renders only the last title 1`] = ` 84 | Array [ 85 | 88 | Title 3 89 | , 90 | ] 91 | `; 92 | -------------------------------------------------------------------------------- /tests/__snapshots__/web.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`mounts and unmounts title 1`] = ` 4 | Array [ 5 | 6 | 7 | Static 8 | 9 | , 10 | , 15 | ] 16 | `; 17 | 18 | exports[`mounts and unmounts title 2`] = ` 19 | Array [ 20 | 21 | 22 | Dynamic 23 | 24 | , 25 | , 30 | ] 31 | `; 32 | 33 | exports[`mounts and unmounts title 3`] = ` 34 | Array [ 35 | 36 | 37 | Static 38 | 39 | , 40 | , 45 | ] 46 | `; 47 | 48 | exports[`renders into document.head portal 1`] = ` 49 |
50 | Yes render 51 | 52 | 53 | Test title 54 | 55 | 56 | 57 | 60 | 61 | 62 | 65 | 66 | 67 | 70 | 71 | 72 | 75 | 76 |
77 | `; 78 | 79 | exports[`renders only last meta with the same property 1`] = ` 80 | Array [ 81 | 82 | 85 | Meta 2 86 | 87 | , 88 | 89 | 92 | Meta 4 93 | 94 | , 95 | 96 | 99 | Meta 6 100 | 101 | , 102 | ] 103 | `; 104 | 105 | exports[`renders only the last meta with the same name 1`] = ` 106 | Array [ 107 | 108 | 109 | Static 1 110 | 111 | , 112 | 113 | 116 | Static 2 117 | 118 | , 119 | , 124 | , 129 | ] 130 | `; 131 | 132 | exports[`renders only the last meta with the same name 2`] = ` 133 | Array [ 134 | 135 | 136 | Static 1 137 | 138 | , 139 | 140 | 143 | Dynamic 1 144 | 145 | , 146 | , 151 | , 156 | ] 157 | `; 158 | 159 | exports[`renders only the last meta with the same name 3`] = ` 160 | Array [ 161 | 162 | 165 | Dynamic 1 166 | 167 | , 168 | , 173 | 174 | 175 | Dynamic 2 176 | 177 | , 178 | , 183 | ] 184 | `; 185 | 186 | exports[`renders only the last meta with the same name 4`] = ` 187 | Array [ 188 | 189 | 190 | Static 1 191 | 192 | , 193 | 194 | 197 | Dynamic 1 198 | 199 | , 200 | , 205 | , 210 | ] 211 | `; 212 | 213 | exports[`renders only the last meta with the same name 5`] = ` 214 | Array [ 215 | 216 | 217 | Static 1 218 | 219 | , 220 | 221 | 224 | Static 2 225 | 226 | , 227 | , 232 | , 237 | ] 238 | `; 239 | 240 | exports[`renders only the last title 1`] = ` 241 | Array [ 242 |
, 243 |
, 244 |
245 | 246 | 247 | Title 3 248 | 249 | 250 |
, 251 | ] 252 | `; 253 | 254 | exports[`switches between titles 1`] = ` 255 | Array [ 256 | 257 | 258 | Static 259 | 260 | , 261 | , 266 | , 271 | ] 272 | `; 273 | 274 | exports[`switches between titles 2`] = ` 275 | Array [ 276 | 277 | 278 | Title 1 279 | 280 | , 281 | , 286 | , 291 | ] 292 | `; 293 | 294 | exports[`switches between titles 3`] = ` 295 | Array [ 296 | , 301 | 302 | 303 | Title 2 304 | 305 | , 306 | , 311 | ] 312 | `; 313 | 314 | exports[`switches between titles 4`] = ` 315 | Array [ 316 | 317 | 318 | Title 1 319 | 320 | , 321 | , 326 | , 331 | ] 332 | `; 333 | -------------------------------------------------------------------------------- /tests/dom.test.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { HeadProvider, Title, Style, Meta, Link, Base } from '../src'; 4 | 5 | test('removes head tags added during ssr', () => { 6 | const root = document.createElement('div'); 7 | document.body.appendChild(root); 8 | 9 | document.head.innerHTML = ` 10 | Test title 11 | 14 | 15 | 16 | `; 17 | 18 | expect(document.head.innerHTML).toMatchSnapshot(); 19 | 20 | ReactDOM.render( 21 | 22 |
23 | Yes render 24 | Test title 25 | 26 | 27 | 28 | 29 |
30 |
, 31 | root 32 | ); 33 | 34 | expect(document.head.innerHTML).toMatchSnapshot(); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/ssr.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import * as React from 'react'; 6 | import { renderToStaticMarkup } from 'react-dom/server'; 7 | import { HeadProvider, Title, Style, Meta, Link, Base } from '../src'; 8 | 9 | test('renders nothing and adds tags to headTags context array', () => { 10 | const arr = []; 11 | const markup = renderToStaticMarkup( 12 | 13 |
14 | Yes render 15 | Title 16 | 17 | 18 | 19 | 20 |
21 |
22 | ); 23 | expect(markup).toMatchSnapshot(); 24 | expect(arr).toMatchSnapshot(); 25 | }); 26 | 27 | test('renders only the last title', () => { 28 | const arr = []; 29 | renderToStaticMarkup( 30 | 31 |
32 | Title 1 33 |
34 |
35 | Title 2 36 |
37 |
38 | Title 3 39 |
40 |
41 | ); 42 | expect(arr).toMatchSnapshot(); 43 | }); 44 | 45 | test('renders only the last meta with the same name', () => { 46 | const arr = []; 47 | renderToStaticMarkup( 48 | 49 | Meta 1 50 | Meta 2 51 | Meta 3 52 | Meta 4 53 | Meta 5 54 | Meta 6 55 | 56 | ); 57 | expect(arr).toMatchSnapshot(); 58 | }); 59 | 60 | test('renders only last meta with the same property', () => { 61 | const arr = []; 62 | renderToStaticMarkup( 63 | 64 | Meta 1 65 | Meta 2 66 | Meta 3 67 | Meta 4 68 | Meta 5 69 | Meta 6 70 | 71 | ); 72 | expect(arr).toMatchSnapshot(); 73 | }); 74 | 75 | test('fails if headTags is not passed to ', () => { 76 | expect(() => { 77 | renderToStaticMarkup( 78 | 79 | 80 | 81 | ); 82 | }).toThrowError(/headTags array should be passed/); 83 | }); 84 | 85 | test('fails if headTags is not an array', () => { 86 | expect(() => { 87 | renderToStaticMarkup( 88 | 89 | 90 | 91 | ); 92 | }).toThrowError(/headTags array should be passed/); 93 | }); 94 | 95 | test('throw error if head tag is rendered without HeadProvider', () => { 96 | const errorFn = jest.spyOn(console, 'error').mockImplementation(() => {}); 97 | expect(() => { 98 | renderToStaticMarkup(); 99 | }).toThrowError(/ should be in the tree/); 100 | errorFn.mockRestore(); 101 | }); 102 | -------------------------------------------------------------------------------- /tests/web.test.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as plug from 'react-powerplug'; 3 | import TestRenderer from 'react-test-renderer'; 4 | import './ReactDOMMock'; 5 | import { HeadProvider, Title, Style, Meta, Link, Base } from '../src'; 6 | 7 | test('renders into document.head portal', () => { 8 | const renderer = TestRenderer.create( 9 | 10 |
11 | Yes render 12 | Test title 13 | 14 | 15 | 16 | 17 |
18 |
19 | ); 20 | expect(renderer.toJSON()).toMatchSnapshot(); 21 | }); 22 | 23 | test('renders only the last title', () => { 24 | const renderer = TestRenderer.create( 25 | 26 |
27 | Title 1 28 |
29 |
30 | Title 2 31 |
32 |
33 | Title 3 34 |
35 |
36 | ); 37 | expect(renderer.toJSON()).toMatchSnapshot(); 38 | }); 39 | 40 | test('mounts and unmounts title', () => { 41 | const renderer = TestRenderer.create( 42 | 43 | Static 44 | 45 | {title => ( 46 | <> 47 | {title.on && Dynamic} 48 | 49 | 50 | )} 51 | 52 | 53 | ); 54 | expect(renderer.toJSON()).toMatchSnapshot(); 55 | // mount 56 | renderer.root.findByType('button').props.onClick(); 57 | expect(renderer.toJSON()).toMatchSnapshot(); 58 | // unmount 59 | renderer.root.findByType('button').props.onClick(); 60 | expect(renderer.toJSON()).toMatchSnapshot(); 61 | }); 62 | 63 | test('switches between titles', () => { 64 | const renderer = TestRenderer.create( 65 | 66 | Static 67 | 68 | {title => ( 69 | <> 70 | {title.value === 0 && Title 1} 71 | 72 | {title.value === 1 && Title 2} 73 | 74 | 75 | )} 76 | 77 | 78 | ); 79 | expect(renderer.toJSON()).toMatchSnapshot(); 80 | // enable 0 81 | renderer.root.findAllByType('button')[0].props.onClick(); 82 | expect(renderer.toJSON()).toMatchSnapshot(); 83 | // switch to 1 84 | renderer.root.findAllByType('button')[1].props.onClick(); 85 | expect(renderer.toJSON()).toMatchSnapshot(); 86 | // switch to 0 87 | renderer.root.findAllByType('button')[0].props.onClick(); 88 | expect(renderer.toJSON()).toMatchSnapshot(); 89 | }); 90 | 91 | test('renders only the last meta with the same name', () => { 92 | const renderer = TestRenderer.create( 93 | 94 | Static 1 95 | Static 2 96 | 97 | {meta => ( 98 | <> 99 | {meta.on && Dynamic 1} 100 | 101 | 102 | )} 103 | 104 | 105 | {meta => ( 106 | <> 107 | {meta.on && Dynamic 2} 108 | 109 | 110 | )} 111 | 112 | 113 | ); 114 | expect(renderer.toJSON()).toMatchSnapshot(); 115 | // mount first 116 | renderer.root.findAllByType('button')[0].props.onClick(); 117 | expect(renderer.toJSON()).toMatchSnapshot(); 118 | // mount second 119 | renderer.root.findAllByType('button')[1].props.onClick(); 120 | expect(renderer.toJSON()).toMatchSnapshot(); 121 | // unmount second 122 | renderer.root.findAllByType('button')[1].props.onClick(); 123 | expect(renderer.toJSON()).toMatchSnapshot(); 124 | // unmount first 125 | renderer.root.findAllByType('button')[0].props.onClick(); 126 | expect(renderer.toJSON()).toMatchSnapshot(); 127 | }); 128 | 129 | test('renders only last meta with the same property', () => { 130 | const renderer = TestRenderer.create( 131 | 132 | Meta 1 133 | Meta 2 134 | Meta 3 135 | Meta 4 136 | Meta 5 137 | Meta 6 138 | 139 | ); 140 | expect(renderer.toJSON()).toMatchSnapshot(); 141 | }); 142 | 143 | test('throws error if head tag is rendered without HeadProvider', () => { 144 | const errorFn = jest.spyOn(console, 'error').mockImplementation(() => {}); 145 | expect(() => { 146 | TestRenderer.create(); 147 | }).toThrowError(/ should be in the tree/); 148 | errorFn.mockRestore(); 149 | }); 150 | --------------------------------------------------------------------------------