Initial commit
commit
d615990529
|
@ -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)
|
|
@ -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()
|
||||
{
|
||||
}
|
||||
}
|
|
@ -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, '&')
|
||||
.replace(/'/g, ''') // '
|
||||
.replace(/"/g, '"') // "
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
module.exports = renderToHtml;
|
|
@ -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;
|
Loading…
Reference in New Issue