react-simple-ssr/virtualRender.js

290 lines
11 KiB
JavaScript

// React Render Emulator
// Simpler alternative to react-test-renderer, also allows to visit element tree during rendering
//
// (c) Vitaliy Filippov 2021+
// Version: 2021-09-21
// 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 !== undefined
? element.props.children
: element.children;
// 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, series = 0;
let lastType = null, lastHasKey = false;
for (let item of tree)
{
let type = item && getType(item);
let key = item && item.key;
if (lastType == type && lastHasKey == (key != null))
index++;
else
{
series++;
index = 0;
lastType = type;
lastHasKey = (key != null);
}
if (key == null)
key = ':'+series+':'+index;
else
key = '['+key+']';
res.push(walkTree(item, visitor, context, options, path+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);
let typeName;
if (Component && Component.name)
typeName = Component.name;
else if (typeof Component == 'symbol')
typeName = Component.toString();
else
typeName = Component;
path += '/'+typeName;
const visitChildren = (render, compInstance, elContext, childContext) =>
{
const result = visitor ? visitor(tree, path, 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);
}
for (let k in newState)
{
if (instance.state[k] !== newState[k])
{
instance.state = { ...instance.state, ...newState };
options.shouldUpdate = true;
break;
}
}
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;