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 <WrappedComponent>{ this.props.children }</WrappedComponent> + } + } + + 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