290 lines
11 KiB
JavaScript
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;
|