// 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) { // // 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); 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;