motion
Version:
motion - moving development forward
624 lines (495 loc) • 17.3 kB
JavaScript
import ReactDOMServer from 'react-dom/server'
import ReactDOM from 'react-dom'
import React from 'react'
import raf from 'raf'
import Radium from './lib/radium'
import phash from './lib/phash'
import cloneError from './lib/cloneError'
import hotCache from './mixins/hotCache'
import reportError from './lib/reportError'
import createElement from './tag/createElement'
import viewOn from './lib/viewOn'
const capitalize = str =>
str[0].toUpperCase() + str.substring(1)
const pathWithoutProps = path =>
path.replace(/\.[a-z0-9\-]+$/, '')
let views = {}
let viewErrorDebouncers = {}
export default function createComponent(Motion, Internal, name, view, options = {}) {
let isChanged = options.changed
// wrap decorators
const wrapComponent = (component) => {
if (Internal.viewDecorator[name])
component = Internal.viewDecorator[name](component)
if (Internal.viewDecorator.all)
component = Internal.viewDecorator.all(component)
return component
}
// production
if (process.env.production)
return wrapComponent(createViewComponent())
// development
views[name] = createViewComponent()
// once rendered, isChanged is used to prevent
// unnecessary props hashing, for faster hot reloads
Motion.on('render:done', () => {
isChanged = false
})
return wrapComponent(createProxyComponent())
// proxy components handle hot reloads
function createProxyComponent() {
return React.createClass({
displayName: `${name}Proxy`,
childContextTypes: {
path: React.PropTypes.string,
displayName: React.PropTypes.string
},
contextTypes: {
path: React.PropTypes.string
},
getChildContext() {
return {
path: this.getPath()
}
},
getPath() {
if (!this.path) this.setPath()
return this.path
},
getSep() {
return name == 'Main' ? '' : ','
},
setPathKey() {
const motion = this.props.__motion
const key = motion && motion.key || '00'
const index = motion && motion.index || '00'
const parentPath = this.context.path || ''
this.pathKey = `${parentPath}${this.getSep()}${name}-${key}/${index}`
},
setPath() {
this.setPathKey()
if (!isChanged) {
const prevPath = Internal.paths[this.pathKey]
if (prevPath) {
this.path = prevPath
return
}
}
const propsHash = phash(this.props)
this.path = `${this.pathKey}.${propsHash}`
// for faster retrieval hot reloading
Internal.paths[this.pathKey] = this.path
},
onMount(component) {
const path = this.getPath()
const lastRendered = component.lastRendered
Internal.mountedViews[name] = Internal.mountedViews[name] || []
Internal.mountedViews[name].push(this)
Internal.viewsAtPath[path] = component
if (lastRendered)
Internal.lastWorkingRenders[pathWithoutProps(path)] = lastRendered
Internal.lastWorkingViews[name] = { component }
},
render() {
const View = views[name]
let viewProps = Object.assign({}, this.props)
viewProps.__motion = viewProps.__motion || {}
viewProps.__motion.onMount = this.onMount
viewProps.__motion.path = this.getPath()
return React.createElement(View, viewProps)
}
})
}
// create view
function createViewComponent() {
const component = React.createClass({
displayName: name,
name,
Motion,
el: createElement,
// set() get() dec()
mixins: [hotCache({ Internal, options, name })],
// TODO: shouldComponentUpdate based on hot load for perf
shouldComponentUpdate() {
return !this.isPaused
},
shouldUpdate(fn) {
if (this.hasShouldUpdate) {
reportError({ message: `You defined shouldUpdate twice in ${name}, remove one!`, fileName: `view ${name}` })
return
}
this.hasShouldUpdate = true
const motionShouldUpdate = this.shouldComponentUpdate.bind(this)
this.shouldComponentUpdate = (nextProps) => {
if (!motionShouldUpdate()) return false
return fn(this.props, nextProps)
}
},
// LIFECYCLES
getInitialState() {
const fprops = this.props.__motion
Internal.getInitialStates[fprops ? fprops.path : 'Main'] = () => this.getInitialState()
let u = null
this.state = {}
this.propDefaults = {}
this.queuedUpdate = false
this.firstRender = true
this.styles = {}
this.events = { mount: u, unmount: u, change: u, props: u }
this.path = null
// scope on() to view
this.on = viewOn(this)
// cache Motion view render() (defined below)
const motionRender = this.render
this.renders = []
// setter to capture view render
this.render = renderFn => {
this.renders.push(renderFn)
}
if (process.env.production)
view.call(this, this, this.on, this.styles)
else {
try {
view.call(this, this, this.on, this.styles)
this.recoveryRender = false
}
catch(e) {
Internal.caughtRuntimeErrors++
reportError(e)
console.error(e.stack || e)
this.recoveryRender = true
}
}
// reset original render
this.render = motionRender
// Motion._onViewInstance
if (Internal.instanceDecorator[name])
Internal.instanceDecorator[name](this)
if (Internal.instanceDecorator.all)
Internal.instanceDecorator.all(this)
return null
},
runEvents(name, args) {
const queue = this.events
if (queue[name] && queue[name].length) {
queue[name].forEach(event => {
event.apply(this, args)
})
}
},
componentWillReceiveProps(nextProps) {
// set timeout becuase otherwise props is mutated before shouldUpdate is run
// setTimeout(() => {
// main doesnt get props
if (name != 'Main') {
this.props = nextProps
this.runEvents('props', [this.props])
}
if (!process.env.production) {
const path = nextProps.__motion.path
const fn = Internal.inspector[path]
const state = _Motion.getCache[nextProps.__motion.path]
// send to inspector if inspecting
fn && fn(nextProps, state)
}
// })
},
componentWillMount() {
// run props before mount
if (name != 'Main') {
this.runEvents('props', [this.props])
}
else {
// moved to here to fix issues where updating during first mount fails
// see: https://github.com/motionjs/motion/issues/305
Internal.firstRender = false
}
},
componentDidMount() {
this.isRendering = false
this.mounted = true
this.runEvents('mount')
if (this.queuedUpdate) {
this.queuedUpdate = false
this.update()
}
if (!process.env.production) {
this.props.__motion.onMount(this)
this.setID()
}
if (this.doRenderToRoot) {
this.handleRootRender()
}
},
componentWillUnmount() {
this.runEvents('unmount')
this.mounted = false
// fixes unmount errors github.com/motionjs/motion/issues/60
if (!process.env.production) this.render()
if (this.doRenderToRoot) {
ReactDOM.unmountComponentAtNode(this.node)
this.app.removeChild(this.node)
}
},
componentWillUpdate() {
this.runEvents('change')
},
setID() {
if (Internal.isDevTools) return
// set motionID for state inspect
const node = ReactDOM.findDOMNode(this)
if (node) node.__motionID = this.props.__motion.path
},
componentDidUpdate() {
this.isRendering = false
if (this.queuedUpdate) {
this.queuedUpdate = false
this.update()
}
if (!process.env.production) {
this.setID()
}
if (this.doRenderToRoot) {
this.handleRootRender()
}
},
// MOTION HELPERS
// view.element('foo') -> <foo>
element(selector) {
const viewNode = ReactDOM.findDOMNode(this)
// Returns itself if a selector is not specified
if (!selector) return viewNode
return viewNode.querySelector(selector)
},
elements(selector) {
const els = ReactDOM.findDOMNode(this).querySelectorAll(selector)
return Array.prototype.slice.call(els, 0)
},
// property declarators
getProp(name) {
return typeof this.props[name] === 'undefined' ?
this.propDefaults[name] :
this.props[name]
},
prop(name, defaultValue) {
this.propDefaults[name] = defaultValue
return this.getProp(name)
},
clone(el, props) {
// TODO better checks and warnings, ie if they dont pass in element just props
if (!el) return el
if (typeof el !== 'object')
throw new Error(`You're attempting to clone something that isn't a tag! In view ${this.name}. Attempted to clone: ${el}`)
// move the parent styles source to the cloned view
if (el.props && el.props.__motion) {
let fprops = el.props.__motion
fprops.parentName = this.name
fprops.parentStyles = this.styles
}
// ok so there was a bug with cloning a view that removes all the view root nodes classes
// this works around it
// TODO this should only be done when dealing with motion views, avoid this for tags
if (props.class || props.className) {
props.__motionAddClass = props.class || props.className
delete props.class
delete props.className
}
return React.cloneElement(el, props)
},
mapElements(children, cb) {
return React.Children.map(children, cb)
},
getName(child) {
const name = child.props && child.props.__motion && child.props.__motion.tagName
// TODO does this always work, what about with react components
return name
},
// helpers for controlling re-renders
pause() { this.isPaused = true },
resume() { this.isPaused = false },
// for looping while waiting
delayUpdate() {
if (this.queuedUpdate) return
this.queuedUpdate = true
this.update()
},
updateSoft() {
this.update(true)
},
// view.set()
update(soft) {
// view.set respects paused
if (soft && this.isPaused) return
// if during a render, wait
if (!this.mounted || Internal.firstRender) {
this.queuedUpdate = true
return
}
// during render, dont update
if (this.isRendering) return
// tools run into weird bug where if error in app on initial render, react gets
// mad that you are trying to re-render tools during app render TODO: strip in prod
// check for isRendering so it shows if fails to render
if (!process.env.production && _Motion.firstRender && _Motion.isRendering)
return setTimeout(this.update)
this.queuedUpdate = false
// rather than setState because we want to skip shouldUpdate calls
this.forceUpdate()
},
// childContextTypes: {
// motionContext: React.PropTypes.object
// },
//
// contextTypes: {
// motionContext: React.PropTypes.object
// },
//
// getChildContext() {
// console.log(name, 'get', this)
// return { motionContext: this._context || null }
// },
//
// // helpers for context
// setContext(obj) {
// if (typeof obj != 'object')
// throw new Error('Must pass an object to childContext!')
//
// console.log(this, name, 'set', obj)
// this.state = { _context: obj }
// },
// render to a "portal"
renderToRoot() {
this.doRenderToRoot = true
this.app = document.body
this.node = document.createElement('div')
this.node.setAttribute('data-portal', 'true')
this.app.appendChild(this.node)
},
inlineStyles() {
this.doRenderInlineStyles = true
},
handleRootRender() {
ReactDOM.render(this.renderResult, this.node)
},
getWrapper(tags, props, numRenders) {
const wrapperName = name.toLowerCase()
let tagProps = Object.assign({ __motionIsWrapper: true }, props)
return this.el(`view.${name}`, tagProps, ...tags)
},
getRender() {
if (this.recoveryRender)
return this.getLastGoodRender()
let tags, props
let addWrapper = true
const numRenders = this.renders && this.renders.length
// no root elements
if (!numRenders) {
tags = []
props = { yield: true }
}
// one root element
else if (numRenders == 1) {
tags = this.renders[0].call(this)
const hasMultipleTags = Array.isArray(tags)
addWrapper = hasMultipleTags || !tags.props
if (!hasMultipleTags && tags.props && !tags.props.root) {
// if tag name == view name
if (tags.props.__motion && tags.props.__motion.tagName != name.toLowerCase()) {
addWrapper = true
tags = [tags]
}
}
}
// multiple root elements
else if (numRenders > 1) {
tags = this.renders.map(r => r.call(this))
}
const wrappedTags = addWrapper ? this.getWrapper(tags, props, numRenders) : tags
const cleanName = name.replace('.', '-')
const viewClassName = `View${cleanName}`
const parentClassName = wrappedTags.props.className
const withParentClass = parentClassName
? `${viewClassName} ${parentClassName}`
: viewClassName
// view.clone (see above) avoids mutating classname until here
// fixes bug: if view.clone(Child, { className: xyz }), would overwrite classes in Child
let clonedClass
if (wrappedTags.props.__motionAddClass) {
clonedClass = `${withParentClass} ${wrappedTags.props.__motionAddClass}`
}
const className = clonedClass || withParentClass
const withClass = React.cloneElement(wrappedTags, { className })
return withClass
},
getLastGoodRender() {
return Internal.lastWorkingRenders[pathWithoutProps(this.props.__motion.path)]
},
// TODO once this works better in 0.15
unstable_handleError(e) {
console.log('ERR', e)
reportError(e)
},
_render() {
const self = this
self.isRendering = true
self.firstRender = false
if (process.env.production)
return self.getRender()
else {
clearTimeout(viewErrorDebouncers[self.props.__motion.path])
}
// try render
try {
const els = self.getRender()
self.lastRendered = els
return els
}
catch(e) {
Internal.caughtRuntimeErrors++
const err = cloneError(e)
const errorDelay = Internal.isLive() ? 1000 : 200
// console warn, with debounce
viewErrorDebouncers[self.props.__motion.path] = setTimeout(() => {
console.groupCollapsed(`Render error in view ${name} (${err.message})`)
console.error(err.stack || err)
console.groupEnd()
// if not in debouncer it shows even after fixing
reportError(e)
}, errorDelay)
const lastRender = self.getLastGoodRender()
try {
let inner = <span>Error in view {name}</span>
if (Internal.isDevTools)
return inner
if (lastRender) {
let __html = ReactDOMServer.renderToString(lastRender)
__html = __html.replace(/\s*data\-react[a-z-]*\=\"[^"]*\"/g, '')
inner = <span dangerouslySetInnerHTML={{ __html }} />
}
// highlight in red and return last working render
return (
<span style={{ display: 'block', position: 'relative' }}>
<span className="__motionError" />
{inner}
</span>
)
}
catch(e) {
console.log("Error rendering last version of view after error")
}
}
},
render() {
let result = this._render.call(this)
if (this.doRenderToRoot) {
this.renderResult = result
return <noscript />
}
else {
return result
}
}
})
return Radium(component)
}
}