Skip to content

Commit

Permalink
support next/head
Browse files Browse the repository at this point in the history
  • Loading branch information
nkzawa committed Oct 7, 2016
1 parent 9150521 commit 89f96cc
Show file tree
Hide file tree
Showing 8 changed files with 268 additions and 7 deletions.
4 changes: 3 additions & 1 deletion client/eval-script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
])

/**
Expand Down
69 changes: 69 additions & 0 deletions client/head-manager.js
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 3 additions & 1 deletion client/next.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
82 changes: 82 additions & 0 deletions lib/head.js
Original file line number Diff line number Diff line change
@@ -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 <title/>.

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)
101 changes: 101 additions & 0 deletions lib/side-effect.js
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 2 additions & 1 deletion server/build/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion server/build/transpile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
]
]
],
Expand Down
9 changes: 6 additions & 3 deletions server/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand Down

0 comments on commit 89f96cc

Please sign in to comment.