diff --git a/client/eval-script.js b/client/eval-script.js
index 32be60b8..2cfe06d3 100644
--- a/client/eval-script.js
+++ b/client/eval-script.js
@@ -3,13 +3,15 @@ import ReactDOM from 'react-dom'
import App from '../lib/app'
import Link from '../lib/link'
import Css from '../lib/css'
+import Head from '../lib/head'
const modules = new Map([
['react', React],
['react-dom', ReactDOM],
['next/app', App],
['next/link', Link],
- ['next/css', Css]
+ ['next/css', Css],
+ ['next/head', Head]
])
/**
diff --git a/client/head-manager.js b/client/head-manager.js
new file mode 100644
index 00000000..4cd5b7fa
--- /dev/null
+++ b/client/head-manager.js
@@ -0,0 +1,69 @@
+import HTMLDOMPropertyConfig from 'react/lib/HTMLDOMPropertyConfig'
+
+const DEFAULT_TITLE = ''
+
+export default class HeadManager {
+ updateHead (head) {
+ const tags = {}
+ head.forEach((h) => {
+ const components = tags[h.type] || []
+ components.push(h)
+ tags[h.type] = components
+ })
+
+ this.updateTitle(tags.title ? tags.title[0] : null)
+
+ const types = ['meta', 'base', 'link', 'style', 'script']
+ types.forEach((type) => {
+ this.updateElements(type, tags[type] || [])
+ })
+ }
+
+ updateTitle (component) {
+ let title
+ if (component) {
+ const { children } = component.props
+ title = 'string' === typeof children ? children : children.join('')
+ } else {
+ title = DEFAULT_TITLE
+ }
+ if (title !== document.title) document.title = title
+ }
+
+ updateElements (type, components) {
+ const headEl = document.getElementsByTagName('head')[0]
+ const oldTags = Array.prototype.slice.call(headEl.querySelectorAll(type + '.next-head'))
+ const newTags = components.map(reactElementToDOM).filter((newTag) => {
+ for (let i = 0, len = oldTags.length; i < len; i++) {
+ const oldTag = oldTags[i]
+ if (oldTag.isEqualNode(newTag)) {
+ oldTags.splice(i, 1)
+ return false
+ }
+ }
+ return true
+ })
+
+ oldTags.forEach((t) => t.parentNode.removeChild(t))
+ newTags.forEach((t) => headEl.appendChild(t))
+ }
+}
+
+function reactElementToDOM ({ type, props }) {
+ const el = document.createElement(type)
+ for (const p in props) {
+ if (!props.hasOwnProperty(p)) continue
+ if ('children' === p || 'dangerouslySetInnerHTML' === p) continue
+
+ const attr = HTMLDOMPropertyConfig.DOMAttributeNames[p] || p.toLowerCase()
+ el.setAttribute(attr, props[p])
+ }
+
+ const { children, dangerouslySetInnerHTML } = props
+ if (dangerouslySetInnerHTML) {
+ el.innerHTML = dangerouslySetInnerHTML.__html || ''
+ } else if (children) {
+ el.textContent = 'string' === typeof children ? children : children.join('')
+ }
+ return el
+}
diff --git a/client/next.js b/client/next.js
index 2dc6ae01..e22b5771 100644
--- a/client/next.js
+++ b/client/next.js
@@ -2,6 +2,7 @@ import { createElement } from 'react'
import { render } from 'react-dom'
import evalScript from './eval-script'
import Router from './router'
+import HeadManager from './head-manager'
import DefaultApp from '../lib/app'
const {
@@ -12,7 +13,8 @@ const App = app ? evalScript(app).default : DefaultApp
const Component = evalScript(component).default
const router = new Router({ Component, props })
+const headManager = new HeadManager()
const container = document.getElementById('__next')
-const appProps = { Component, props, router }
+const appProps = { Component, props, router, headManager }
render(createElement(App, { ...appProps }), container)
diff --git a/lib/head.js b/lib/head.js
new file mode 100644
index 00000000..d547c963
--- /dev/null
+++ b/lib/head.js
@@ -0,0 +1,82 @@
+import React from 'react'
+import sideEffect from './side-effect'
+
+class Head extends React.Component {
+ static contextTypes = {
+ headManager: React.PropTypes.object
+ }
+
+ render () {
+ return null
+ }
+
+ componentWillUnmount () {
+ this.context.headManager.updateHead([])
+ }
+}
+
+function reduceComponents (components) {
+ return components
+ .map((c) => c.props.children)
+ .filter((c) => !!c)
+ .map((children) => React.Children.toArray(children))
+ .reduce((a, b) => a.concat(b), [])
+ .reverse()
+ .filter(unique())
+ .reverse()
+ .map((c) => {
+ const className = (c.className ? c.className + ' ' : '') + 'next-head'
+ return React.cloneElement(c, { className })
+ })
+}
+
+function mapOnServer (head) {
+ return head
+}
+
+function onStateChange (head) {
+ if (this.context && this.context.headManager) {
+ this.context.headManager.updateHead(head)
+ }
+}
+
+const METATYPES = ['name', 'httpEquiv', 'charSet', 'itemProp']
+
+// returns a function for filtering head child elements
+// which shouldn't be duplicated, like
.
+
+function unique () {
+ const tags = new Set()
+ const metaTypes = new Set()
+ const metaCategories = {}
+
+ return (h) => {
+ switch (h.type) {
+ case 'title':
+ case 'base':
+ if (tags.has(h.type)) return false
+ tags.add(h.type)
+ break
+ case 'meta':
+ for (let i = 0, len = METATYPES.length; i < len; i++) {
+ const metatype = METATYPES[i]
+ if (!h.props.hasOwnProperty(metatype)) continue
+
+ if ('charSet' === metatype) {
+ if (metaTypes.has(metatype)) return false
+ metaTypes.add(metatype)
+ } else {
+ const category = h.props[metatype]
+ const categories = metaCategories[metatype] || new Set()
+ if (categories.has(category)) return false
+ categories.add(category)
+ metaCategories[metatype] = categories
+ }
+ }
+ break
+ }
+ return true
+ }
+}
+
+export default sideEffect(reduceComponents, onStateChange, mapOnServer)(Head)
diff --git a/lib/side-effect.js b/lib/side-effect.js
new file mode 100644
index 00000000..7cb0483c
--- /dev/null
+++ b/lib/side-effect.js
@@ -0,0 +1,101 @@
+import React, { Component } from 'react'
+
+export default function withSideEffect (reduceComponentsToState, handleStateChangeOnClient, mapStateOnServer) {
+ if (typeof reduceComponentsToState !== 'function') {
+ throw new Error('Expected reduceComponentsToState to be a function.')
+ }
+
+ if (typeof handleStateChangeOnClient !== 'function') {
+ throw new Error('Expected handleStateChangeOnClient to be a function.')
+ }
+
+ if (typeof mapStateOnServer !== 'undefined' && typeof mapStateOnServer !== 'function') {
+ throw new Error('Expected mapStateOnServer to either be undefined or a function.')
+ }
+
+ function getDisplayName (WrappedComponent) {
+ return WrappedComponent.displayName || WrappedComponent.name || 'Component'
+ }
+
+ return function wrap (WrappedComponent) {
+ if (typeof WrappedComponent !== 'function') {
+ throw new Error('Expected WrappedComponent to be a React component.')
+ }
+
+ const mountedInstances = new Set()
+ let state
+ let shouldEmitChange = false
+
+ function emitChange (component) {
+ state = reduceComponentsToState([...mountedInstances])
+
+ if (SideEffect.canUseDOM) {
+ handleStateChangeOnClient.call(component, state)
+ } else if (mapStateOnServer) {
+ state = mapStateOnServer(state)
+ }
+ }
+
+ function maybeEmitChange (component) {
+ if (!shouldEmitChange) return
+ shouldEmitChange = false
+ emitChange(component)
+ }
+
+ class SideEffect extends Component {
+ // Try to use displayName of wrapped component
+ static displayName = `SideEffect(${getDisplayName(WrappedComponent)})`
+
+ static contextTypes = WrappedComponent.contextTypes
+
+ // Expose canUseDOM so tests can monkeypatch it
+ static canUseDOM = 'undefined' !== typeof window
+
+ static peek () {
+ return state
+ }
+
+ static rewind () {
+ if (SideEffect.canUseDOM) {
+ throw new Error('You may only call rewind() on the server. Call peek() to read the current state.')
+ }
+
+ maybeEmitChange()
+
+ const recordedState = state
+ state = undefined
+ mountedInstances.clear()
+ return recordedState
+ }
+
+ componentWillMount () {
+ mountedInstances.add(this)
+ shouldEmitChange = true
+ }
+
+ componentDidMount () {
+ maybeEmitChange(this)
+ }
+
+ componentWillUpdate () {
+ shouldEmitChange = true
+ }
+
+ componentDidUpdate () {
+ maybeEmitChange(this)
+ }
+
+ componentWillUnmount () {
+ mountedInstances.delete(this)
+ shouldEmitChange = false
+ emitChange(this)
+ }
+
+ render () {
+ return { this.props.children }
+ }
+ }
+
+ return SideEffect
+ }
+}
diff --git a/server/build/bundle.js b/server/build/bundle.js
index 0a0d3291..b8816f37 100644
--- a/server/build/bundle.js
+++ b/server/build/bundle.js
@@ -15,7 +15,8 @@ export default function bundle (src, dst) {
{
[require.resolve('react')]: 'react',
[require.resolve('../../lib/link')]: 'next/link',
- [require.resolve('../../lib/css')]: 'next/css'
+ [require.resolve('../../lib/css')]: 'next/css',
+ [require.resolve('../../lib/head')]: 'next/head'
}
],
resolveLoader: {
diff --git a/server/build/transpile.js b/server/build/transpile.js
index 407acb3a..a7d464cd 100644
--- a/server/build/transpile.js
+++ b/server/build/transpile.js
@@ -26,7 +26,8 @@ const babelOptions = {
{ src: `npm:${babelRuntimePath}`, expose: 'babel-runtime' },
{ src: `npm:${require.resolve('react')}`, expose: 'react' },
{ src: `npm:${require.resolve('../../lib/link')}`, expose: 'next/link' },
- { src: `npm:${require.resolve('../../lib/css')}`, expose: 'next/css' }
+ { src: `npm:${require.resolve('../../lib/css')}`, expose: 'next/css' },
+ { src: `npm:${require.resolve('../../lib/head')}`, expose: 'next/head' }
]
]
],
diff --git a/server/render.js b/server/render.js
index 3bc94529..a478c10e 100644
--- a/server/render.js
+++ b/server/render.js
@@ -3,6 +3,7 @@ import { createElement } from 'react'
import { renderToString, renderToStaticMarkup } from 'react-dom/server'
import fs from 'mz/fs'
import Document from '../lib/document'
+import Head from '../lib/head'
import App from '../lib/app'
import { StyleSheetServer } from '../lib/css'
@@ -28,10 +29,12 @@ export async function render (path, req, res, { dir = process.cwd(), dev = false
return renderToString(app)
})
+ const head = Head.rewind() || []
+
const doc = createElement(Document, {
- head: [],
- html: html,
- css: css,
+ html,
+ head,
+ css,
data: { component },
hotReload: false,
dev