Initial commit

master
Vitaliy Filippov 2021-08-08 00:46:45 +03:00
commit d615990529
4 changed files with 578 additions and 0 deletions

51
README.md Normal file
View File

@ -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(<Component {...props}>, options);
while (options.shouldUpdate)
{
html = virtualRender(<Component {...props}>, options);
}
```
## Author and License
Author: Vitaliy Filippov, 2021+
License: Dual-license MPL 1.1+ or GNU LGPL 3.0+ (file-level copyleft)

129
SSRComponent.js Normal file
View File

@ -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()
{
}
}

128
renderToHtml.js Normal file
View File

@ -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)+'</'+tag+'>';
}
else
{
html += ' />';
}
return html;
}
else
{
return renderToHtml(tree.children);
}
}
else if (tree != null)
{
return htmlspecialchars(tree);
}
return '';
}
function htmlspecialchars(text)
{
return (''+text).replace(/&/g, '&amp;')
.replace(/'/g, '&apos;') // '
.replace(/"/g, '&quot;') // "
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
module.exports = renderToHtml;

270
virtualRender.js Normal file
View File

@ -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)
{
// <Provider>
// eslint-disable-next-line no-param-reassign
tree.type._context._currentValue = tree.props.value;
}
if (typeof tree.props.children === "function")
{
// <Consumer>
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;