Last active
July 9, 2018 20:06
-
-
Save aapoalas/b5a49849c458b124d599d3950034e507 to your computer and use it in GitHub Desktop.
React Component supporting reparenting through a manual DOM hack. Presume MIT licence.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
define([ | |
"react", | |
"react-dom", | |
"prop-types" | |
], function( | |
React, | |
ReactDOM, | |
PropTypes | |
) { | |
"use strict"; | |
const { PureComponent } = React; | |
const ATTRIBUTE_MAP = Object.freeze({ | |
acceptCharset: "accept-charset", | |
httpEquiv: "http-equiv" | |
}); | |
const getAttrName = attr => ATTRIBUTE_MAP[attr] || attr; | |
const store = {}; | |
const getOrCreateMountNode = (uid, type = "div") => { | |
if (!store[uid]) { | |
const mountNode = document.createElement(type); | |
store[uid] = { | |
mountNode, | |
inUse: true | |
}; | |
} else { | |
store[uid].inUse = true; | |
if (store[uid].mountNode.nodeName.toLowerCase() !== type) { | |
const newNode = document.createElement(type); | |
ReactDOM.unmountComponentAtNode(store[uid].mountNode); | |
store[uid].mountNode = newNode; | |
} | |
} | |
return store[uid].mountNode; | |
}; | |
const calculateChangeSet = (prev = {}, next = {}) => { | |
const set = []; | |
const del = Object.entries(prev).reduce((acc, [name, value]) => { | |
const nextValue = next[name]; | |
if (nextValue === undefined || nextValue === null || nextValue === "") { | |
return acc.concat(name); | |
} else if (value !== nextValue) { | |
set.push([name, value]); | |
} | |
return acc; | |
}, []); | |
Object.entries(next).forEach(entry => { | |
const [name, value] = entry; | |
if (prev.hasOwnProperty(name) || value === undefined || value === null || value === "") { | |
// Already handled or invalid | |
return; | |
} | |
set.push(entry); | |
}); | |
return { | |
del, | |
set | |
}; | |
}; | |
const updateNodeStyle = ( | |
node, | |
prevClassName, | |
nextClassName, | |
prevStyle, | |
nextStyle, | |
prevAttrs, | |
nextAttrs | |
) => { | |
const { | |
del: delAttrs, | |
set: setAttrs | |
} = calculateChangeSet(prevAttrs, nextAttrs); | |
delAttrs.map(name => node.removeAttribute(name)); | |
setAttrs.map(([name, value]) => node.setAttribute(getAttrName(name), value)); | |
const { | |
del: delStyle, | |
set: setStyle | |
} = calculateChangeSet(prevStyle, nextStyle); | |
delStyle.map(name => node.style[name] = ""); | |
setStyle.map(([name, value]) => node.style[name] = value); | |
if (setStyle.length === 0 && node.style.cssText === "") { | |
// We should remove the empty style attribute | |
node.removeAttribute("style"); | |
} | |
if (prevClassName !== nextClassName) { | |
(nextClassName && nextClassName.length > 0) ? | |
node.setAttribute("class", nextClassName) : | |
node.removeAttribute("class"); | |
} | |
}; | |
// TODO: Diff with existing attributes if any present | |
const setNodeStyle = (node, className = "", style = {}, attrs = {}) => { | |
if (node.hasAttributes()) { | |
const attributes = node.attributes; | |
for (let i = 0; i < attributes.length; i++) { | |
const name = attributes[i].name; | |
node.removeAttribute(name); | |
} | |
} | |
if (typeof attrs === "object" && style !== null) { | |
Object.entries(attrs).map(([name, value]) => node.setAttribute(getAttrName(name), value)); | |
} | |
if (typeof style === "object" && style !== null) { | |
Object.entries(style).map(([name, value]) => node.style[name] = value); | |
} | |
if (typeof className === "string" && className.length > 0) { | |
node.setAttribute("class", className); | |
} | |
}; | |
const removeMountNode = uid => { | |
const record = store[uid]; | |
record.inUse = false; | |
setTimeout(() => | |
!store[uid].inUse && (ReactDOM.unmountComponentAtNode(store[uid].mountNode) && delete store[uid]), 100); | |
}; | |
const Reparentable = class Reparentable extends PureComponent { | |
componentDidMount() { | |
const { | |
className, | |
el, | |
style, | |
type, | |
uid | |
} = this.props; | |
const mountNode = getOrCreateMountNode(uid, type); | |
setNodeStyle(mountNode, className, style); | |
this.el = el; | |
this.el.appendChild(mountNode); | |
this.renderContentIntoNode(mountNode); | |
} | |
componentDidUpdate({ | |
className: prevClassName, | |
el: prevEl, | |
style: prevStyle, | |
type: prevType, | |
uid: prevUid | |
}) { | |
const { | |
className, | |
el, | |
style, | |
type, | |
uid | |
} = this.props; | |
const mountNode = getOrCreateMountNode(uid, type); | |
updateNodeStyle(mountNode, prevClassName, className, prevStyle, style); | |
if (el !== prevEl) { | |
prevEl.removeChild(mountNode); | |
this.el = el; | |
prevEl.appendChild(mountNode); | |
} | |
this.renderContentIntoNode(mountNode); | |
} | |
renderContentIntoNode(node) { | |
const { children } = this.props; | |
ReactDOM.unstable_renderSubtreeIntoContainer(this, children, node); | |
} | |
render() { | |
return null; | |
} | |
componentWillUnmount() { | |
removeMountNode(this.props.uid); | |
} | |
}; | |
Reparentable.propTypes = { | |
className: PropTypes.string, | |
el: PropTypes.instanceOf(Element).isRequired, | |
style: PropTypes.object, | |
type: PropTypes.string.isRequired, | |
uid: PropTypes.string.isRequired | |
}; | |
Reparentable.defaultProps = { | |
className: "", | |
style: {}, | |
type: "div" | |
}; | |
return (type, props, children) => h(Reparentable, Object.assign({ type }, props), children); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment