commit d61599052924a7c6f0ade67ec04f5835af857234 Author: Vitaliy Filippov Date: Sun Aug 8 00:46:45 2021 +0300 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c6f594 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +This is a simple base component class for true server-side rendering +with emulation of server interaction. + +It is intended for use with **class components** and **without** any +external state management libraries like Redux. Basically it's intended +for pure setState()-based code. + +## Algorithm + +- Write code in such a way that it still works if DOM is not available +- Make your components inherit from `SSRComponent`: + - Use `doRender` instead of `render` + - Use `init` instead of `constructor` + - Everything else stays the same + - You can additionally override `serializeState` and `unserializeState` + if your state isn't directly JSON-serializable +- Implement SSR: + - Declare `store = {}` + - Render your component using `react-test-renderer` with `store={store}` in props + - Make sure your code tracks all fetch() requests (server interactions) during render + - And of course mock fetch() (or analogue) to use internal server-side calls + - Wait until all fetch() requests complete + - Re-render until the component stops issuing additional requests + - Render HTML using `renderToHtml(testRenderer.toJSON())` + - Serialize state using `JSON.stringify(SSRComponent.serializeStore(store))` +- Hydrate the rendered HTML in your frontend code: + - Call `ReactDOM.hydrate()` instead of `ReactDOM.render()` + - Pass serialized state from the last step of SSR to your top-level SSRComponent + +## virtualRender.js + +Simpler (but less compatible) alternative to `react-test-renderer`. + +USAGE: + +```jsx +import virtualRender from 'virtualRender.js'; + +const options = {}; +let html = virtualRender(, options); +while (options.shouldUpdate) +{ + html = virtualRender(, options); +} +``` + +## Author and License + +Author: Vitaliy Filippov, 2021+ + +License: Dual-license MPL 1.1+ or GNU LGPL 3.0+ (file-level copyleft) diff --git a/SSRComponent.js b/SSRComponent.js new file mode 100644 index 0000000..0b047c1 --- /dev/null +++ b/SSRComponent.js @@ -0,0 +1,129 @@ +// Component capable of saving & restoring state during render +// (c) Vitaliy Filippov 2019+ +// Version: 2021-08-07 +// License: Dual-license MPL 1.1+ or GNU LGPL 3.0+ + +// NOTE: Child components of the same class should have unique `key`s + +import React from 'react'; + +export default class SSRComponent extends React.PureComponent +{ + constructor(props) + { + super(props); + if (props.store && props.store.state) + { + this.unserializeState(props.store.state); + delete props.store.state; + } + else + { + this.state = {}; + this.init(props); + } + } + + init(props) + { + } + + static serializeStore(store) + { + store = { ...store }; + if (store.instance) + { + store.state = store.instance.serializeState(); + delete store.instance; + } + if (store.children) + { + store.children = { ...store.children }; + for (const className in store.children) + { + store.children[className] = { ...store.children[className] }; + for (const key in store.children[className]) + { + store.children[className][key] = SSRComponent.serializeStore(store.children[className][key]); + } + } + } + return store; + } + + serializeState() + { + return { ...this.state }; + } + + unserializeState(state) + { + this.state = state; + } + + passStore(children) + { + return React.Children.map(children, (child) => + { + if (child && (child.type instanceof Object) && (child.type.prototype instanceof SSRComponent)) + { + const className = child.type.name; + this.props.store.idx[className] ||= { num: 0, keys: {} }; + let key; + if (child.props.key) + key = ':'+child.props.key; + else + { + this.props.store.idx[className].num++; + key = '['+this.props.store.idx[className].num; + } + this.props.store.idx[className].keys[key] = true; + let chstore = (this.props.store.children ||= {}); + chstore = (chstore[className] ||= {}); + chstore = (chstore[key] ||= {}); + return React.cloneElement(child, { + store: chstore, + children: child.props && child.props.children ? this.passStore(child.props.children) : undefined, + }); + } + else if (child && child.props && child.props.children) + { + return React.cloneElement(child, { + children: this.passStore(child.props.children), + }); + } + return child; + }); + } + + render() + { + if (this.props.store) + { + this.props.store.instance = this; + this.props.store.idx = {}; + } + let children = this.doRender(); + if (this.props.store) + { + children = this.passStore(children); + // Clear unused keys + for (let className in this.props.store.idx) + { + for (let key in this.props.store.children[className]) + { + if (!this.props.store.idx[className].keys[key]) + { + delete this.props.store.children[className][key]; + } + } + } + delete this.props.store.idx; + } + return children; + } + + doRender() + { + } +} diff --git a/renderToHtml.js b/renderToHtml.js new file mode 100644 index 0000000..f4c123b --- /dev/null +++ b/renderToHtml.js @@ -0,0 +1,128 @@ +// A function that converts react-test-renderer's toJSON() result +// to HTML usable for React.hydrate() +// +// (c) Vitaliy Filippov 2021+ +// License: Dual-license MPL 1.1+ or GNU LGPL 3.0+ + +const enclosedTags = { + br: true, + hr: true, + input: true, + img: true, + link: true, + source: true, + col: true, + area: true, + base: true, + meta: true, + embed: true, + param: true, + track: true, + wbr: true, + keygen: true, +}; + +const boolAttrs = { + checked: true, + selected: true, + readonly: true, + defer: true, + deferred: true, + disabled: true, + hidden: true, + multiple: true, + required: true, + reversed: true, + selected: true, +}; + +function renderToHtml(tree) +{ + if (tree instanceof Array) + { + return tree.map(renderToHtml).join(''); + } + else if (tree instanceof Object) + { + if (typeof(tree.type) == 'string') + { + let tag = tree.type.toLowerCase(); + let children = tree.children; + let html = '<'+tag; + let k, v; + let esc = true; + for (k in tree.props) + { + v = tree.props[k]; + k = k.toLowerCase(); + if (k == 'classname') + { + k = 'class'; + } + else if (k == 'htmlfor') + { + k = 'for'; + } + else if (k == 'xlinkhref') + { + k = 'xlink:href'; + } + else if (boolAttrs[k]) + { + if (v) + html += ' '+k; + continue; + } + else if (k == 'style' && v instanceof Object) + { + v = Object.keys(v).map(sk => sk.replace(/[A-Z]/g, m => '-'+m.toLowerCase())+': '+v[sk]); + } + else if (k == 'value' && tag == 'textarea') + { + children = v; + continue; + } + else if (k == 'dangerouslysetinnerhtml') + { + children = v == null ? '' : v; + esc = false; + continue; + } + if (v == null || typeof v == 'function') + { + continue; + } + html += ' '+k+'="'+htmlspecialchars(''+v)+'"'; + } + if (!enclosedTags[tag]) + { + html += '>'+(esc ? renderToHtml(children) : children)+''; + } + else + { + html += ' />'; + } + return html; + } + else + { + return renderToHtml(tree.children); + } + } + else if (tree != null) + { + return htmlspecialchars(tree); + } + return ''; +} + +function htmlspecialchars(text) +{ + return (''+text).replace(/&/g, '&') + .replace(/'/g, ''') // ' + .replace(/"/g, '"') // " + .replace(//g, '>'); +} + +module.exports = renderToHtml; diff --git a/virtualRender.js b/virtualRender.js new file mode 100644 index 0000000..30677bb --- /dev/null +++ b/virtualRender.js @@ -0,0 +1,270 @@ +// React Render Emulator +// Simpler alternative to react-test-renderer, also allows to visit element tree during rendering +// +// (c) Vitaliy Filippov 2021+ +// License: Dual-license MPL 1.1+ or GNU LGPL 3.0+ +// Credits to react-apollo/getDataFromTree.ts and react-tree-walker + +const defaultOptions = { + componentWillUnmount: true +}; + +const forwardRefSymbol = Symbol.for("react.forward_ref"); + +const ensureChild = child => (child && typeof child.render === "function" ? ensureChild(child.render()) : child); + +// Preact puts children directly on element, and React via props +const getChildren = element => + element.props && element.props.children ? element.props.children : element.children ? element.children : undefined; + +// Preact uses "nodeName", React uses "type" +const getType = element => element.type || element.nodeName; + +// Preact uses "attributes", React uses "props" +const getProps = element => element.props || element.attributes; + +const isReactElement = element => !!getType(element); + +const isClassComponent = Comp => + Comp.prototype && (Comp.prototype.render || Comp.prototype.isReactComponent || Comp.prototype.isPureReactComponent); + +const isForwardRef = Comp => Comp.type && Comp.type.$$typeof === forwardRefSymbol; + +const providesChildContext = instance => !!instance.getChildContext; + +// Runs a virtual render pass on reactElement synchronously, triggering most +// lifecycle methods, persisting constructed instances in options.instanceStore, +// and setting options.shouldUpdate to true on state changes. +// You should do an additional pass with the same `options` object if options.shouldUpdate +// is true after rendering. +// Runs the provided optional visitor against each React element. +// +// @param options.visitor function(element, componentKey, componentInstance, context, childContext) +function virtualRender(reactElement, options) +{ + options.shouldUpdate = false; + options.liveInstances = {}; + const result = walkTree(reactElement, options.visitor, options.context, options); + for (const k in options.instanceStore) + { + if (!options.liveInstances[k]) + { + if (options.instanceStore[k].componentWillUnmount) + { + options.instanceStore[k].componentWillUnmount(); + } + delete options.instanceStore[k]; + } + } + return result; +} + +// Recurse a React Element tree, running the provided visitor against each element. +// If a visitor call returns `false` then we will not recurse into the respective +// elements children. +function walkTree(tree, visitor, context, options, path = '') +{ + if (!options) + { + options = { ...defaultOptions }; + } + options.instanceStore = options.instanceStore || {}; + if (!tree) + { + return tree; + } + if (typeof tree === "string" || typeof tree === "number") + { + // Just visit these, they are leaves so we don't keep traversing. + if (visitor) + visitor(tree, null, null, context); + return tree; + } + if (tree instanceof Array) + { + // Process array, remembering keys and indices within series of elements of the same type + let res = []; + let index = 0; + let lastType = null; + for (let item of tree) + { + let type = item && getType(item); + if (lastType == type) + index++; + else + index = 0; + lastType = type; + let key = item && getProps(item)?.key || index; + let typeName; + if (type && type.name) + typeName = type.name; + else if (typeof type == 'symbol') + typeName = type.toString(); + else + typeName = type; + res.push(walkTree(item, visitor, context, options, path+'/'+typeName+'['+key+']')); + } + return res; + } + if (tree.type) + { + const _context = tree.type._context || (tree.type.Provider && tree.type.Provider._context); + if (_context) + { + if ("value" in tree.props) + { + // + // eslint-disable-next-line no-param-reassign + tree.type._context._currentValue = tree.props.value; + } + if (typeof tree.props.children === "function") + { + // + const el = tree.props.children(_context._currentValue); + return walkTree(el, visitor, context, options, path); + } + } + } + if (isReactElement(tree)) + { + const Component = getType(tree); + const visitChildren = (render, compInstance, elContext, childContext) => + { + const result = visitor ? visitor(tree, compKey, compInstance, elContext, childContext) : true; + if (result !== false) + { + // A false wasn't returned so we will attempt to visit the children + // for the current element. + const tempChildren = render(); + const children = ensureChild(tempChildren); + return walkTree(children, visitor, childContext, options, path); + } + return result; + }; + if (typeof Component === "function" || isForwardRef(tree)) + { + const props = { + ...Component.defaultProps, + ...getProps(tree), + // For Preact support so that the props get passed into render function. + children: getChildren(tree), + }; + if (isForwardRef(tree)) + { + return visitChildren(() => tree.type.render(props), null, context, context); + } + else if (isClassComponent(Component)) + { + // Class component + let prevProps, prevState; + let instance, isnew = false; + if (options.instanceStore[path] && + (options.instanceStore[path] instanceof Component)) + { + // Persist instances + instance = options.instanceStore[path]; + prevProps = instance.props; + prevState = instance.state; + // FIXME: Ideally, we should remember render output and run shouldComponentUpdate() + // to avoid extra updates + } + else + { + instance = new Component(props, context); + options.instanceStore[path] = instance; + isnew = true; + // set the instance state to null (not undefined) if not set, to match React behaviour + instance.state ||= null; + // Create a synchronous setState() + // FIXME: Ideally, we should mock the React/Preact parent class instead of assigning + // instance.setState directly + instance.setState = (newState, callback) => + { + if (typeof newState === "function") + { + // eslint-disable-next-line no-param-reassign + newState = newState(instance.state, instance.props, instance.context); + } + instance.state = { ...instance.state, ...newState }; + options.shouldUpdate = true; + if (callback) + { + callback(); + } + }; + } + options.liveInstances[path] = true; + instance.props = props; + instance.context = context; + if (Component.getDerivedStateFromProps) + { + const result = Component.getDerivedStateFromProps(instance.props, instance.state); + if (result !== null) + instance.state = { ...instance.state, ...result }; + } + if (isnew) + { + if (instance.UNSAFE_componentWillMount) + instance.UNSAFE_componentWillMount(); + else if (instance.componentWillMount) + instance.componentWillMount(); + } + const childContext = providesChildContext(instance) + ? Object.assign({}, context, instance.getChildContext()) + : context; + const r = visitChildren( + // Note: preact API also allows props and state to be referenced + // as arguments to the render func, so we pass them through here + () => instance.render(instance.props, instance.state), + instance, context, childContext + ); + if (isnew) + { + if (instance.componentDidMount) + instance.componentDidMount(); + } + else + { + if (instance.componentDidUpdate) + instance.componentDidUpdate(prevProps, prevState); + } + if (options.componentWillUnmount && instance.componentWillUnmount) + { + instance.componentWillUnmount(); + delete options.instanceStore[path]; + } + return r; + } + else + { + // Stateless Functional Component + return visitChildren(() => Component(props, context), null, context, context); + } + } + else + { + // A basic element, such as a dom node, string, number etc. + let props = getProps(tree); + if (props) + { + props = { ...props }; + delete props.children; + } + return { + type: Component, + props, + children: visitChildren(() => getChildren(tree), null, context, context), + }; + } + } + // Portals + if (tree.containerInfo && tree.children && tree.children.props && + (tree.children.props.children instanceof Array)) + { + walkTree(tree.children.props.children, visitor, context, options, path+'//Portal'); + return null; + } + return tree; +} + +module.exports = virtualRender;