shallow-react-snapshot
Version:
Enzyme-like shallow snapshots with React Testing Library
369 lines (368 loc) • 14.1 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.shallow = shallow;
const reactSymbols_1 = require("./reactSymbols");
const testSymbol = typeof Symbol === "function" && Symbol.for
? Symbol.for("react.test.json")
: 245830487;
/**
* Transforms a HTML element into a shallow representation of a React component
*/
function shallow(rootElement, RootReactComponent) {
var _a;
if (rootElement === null) {
return null;
}
if (!RootReactComponent) {
throw new Error("Shallow: Expected syntax 'shallow(container, RootReactComponent)'. Did you forget to provide RootReactComponent? This is usually the same component that you are testing. See the documentation for more information.");
}
const fiberOrInternalInstance = getFirstNestedFiberOrInternalInstance(rootElement);
if (!fiberOrInternalInstance) {
throw new Error("Shallow: No React component found in the provided element, or its children. Are you sure there is a React component rendered?");
}
let rootReactComponent = getFirstChildOfRootReactComponent(fiberOrInternalInstance, RootReactComponent);
// If the root component is using state, then the props that we are seeing might not be up-to-date
if ((_a = rootReactComponent.return) === null || _a === void 0 ? void 0 : _a.memoizedState) {
const rootReactComponentCandidate = findCurrentlyRenderedState(rootReactComponent.return).child;
if (!rootReactComponentCandidate) {
throw new Error("Shallow: Unable to find the currently rendered state. This should not happen. Please, report this issue.");
}
rootReactComponent = rootReactComponentCandidate;
}
return renderReactComponentWithChildren(rootReactComponent);
}
/**
* Search through alternate versions of current node to find the currently rendered state
*/
function findCurrentlyRenderedState(node, history = []) {
let current = node;
const isLastRenderedState = isClassComponentState(current)
? isLastRenderedStateClassComponent
: isLastRenderedStateFunctionalComponent;
while (!isLastRenderedState(current)) {
// There should always be an alternate component
if (!current.alternate) {
throw new Error("Shallow: Unable to find the currently rendered state. There is no alternate component. This should not happen. Please, report this issue.");
}
// This is here just to make sure we don't end up in an infinite loop
if (history.includes(current)) {
throw new Error("Shallow: Unable to find the currently rendered state. There is a circular reference. This should not happen. Please, report this issue.");
}
history.push(current);
current = current.alternate;
}
return current;
}
/**
* Checks if the node state definitions are specific for class components
*/
function isClassComponentState(node) {
return !node._debugHookTypes; // Debug hook types are available only in functional components
}
/**
* Checks if the node state is the last rendered state (works for functional components)
*/
function isLastRenderedStateFunctionalComponent(node) {
// There is no queue, which means it is the last rendered state
if (!node.memoizedState.queue) {
return true;
}
return (node.memoizedState.memoizedState ===
node.memoizedState.queue.lastRenderedState);
}
/**
* Checks if the node state is the last rendered state (works for class components)
*/
function isLastRenderedStateClassComponent(node) {
// There is no queue, which means it is the last rendered state
if (!node.updateQueue) {
return true;
}
// React 16
if (node.updateQueue.lastBaseUpdate === undefined) {
return node.updateQueue.baseQueue === null;
}
// React 17+
return node.updateQueue.lastBaseUpdate === null;
}
/**
* Get first nested React Fiber or InternalInstance from a HTML element
*/
function getFirstNestedFiberOrInternalInstance(rootElement) {
let current = rootElement;
let fiberOrInternalInstance = getFiberOrInternalInstance(current);
// If the root element is not a React component, then we need to find the first child that is a React component
while (!fiberOrInternalInstance && current.firstElementChild) {
current = current.firstElementChild;
fiberOrInternalInstance = getFiberOrInternalInstance(current);
}
return fiberOrInternalInstance;
}
/**
* Transforms React components in filter to their actual components
* This is needed because React components are sometimes wrapped in React.memo or React.forwardRef
*/
function transformRootReactComponent(RootReactComponent) {
if (typeof RootReactComponent === "string") {
return RootReactComponent;
}
if (reactSymbols_1.Memo && RootReactComponent.$$typeof === reactSymbols_1.Memo) {
return RootReactComponent.type;
}
return RootReactComponent;
}
/**
* Climb up the tree to find the root React component matching the filter
*/
function getFirstChildOfRootReactComponent(fiberOrInternalInstance, RootReactComponent) {
const componentStorage = [];
const TransformedRootReactComponent = transformRootReactComponent(RootReactComponent);
let current = fiberOrInternalInstance;
// Find first component related to our filter
// First we check in parent components
while (current.return &&
!isParentComponentMatching(current, TransformedRootReactComponent, componentStorage)) {
current = current.return;
}
// If there is no matching parent component, then we need to check in children
// If a child has a sibling, then it is a hint, that something is wrong and we abort
// We could support search in siblings, but I cannot imagine a real-life scenario where it would be useful
while (current.child &&
!current.child.sibling &&
!isParentComponentMatching(current, TransformedRootReactComponent, componentStorage)) {
current = current.child;
}
// If we didn't find any matching component, then we throw an error
if (!isParentComponentMatching(current, TransformedRootReactComponent)) {
const componentsInfo = componentStorage.length > 0
? `Found components:\n ${componentStorage.join("\n ")}`
: "No components found in the tree.";
throw new Error(`Shallow: None of the rendered components matches the provided RootReactComponent "${getComponentDisplayName(RootReactComponent)}"\n\n${componentsInfo}`);
}
return current;
}
/**
* Get React Fiber or InternalInstance from a HTML element
*/
function getFiberOrInternalInstance(element) {
var _a;
return (_a = Object.entries(element).find(([key]) => key.startsWith("__reactFiber$") || // Functional component
key.startsWith("__reactInternalInstance$"))) === null || _a === void 0 ? void 0 : _a[1];
}
/**
* Transform React component into a JSON representation
*/
function renderReactComponentWithChildren(reactComponent) {
if (typeof reactComponent !== "object") {
return reactComponent;
}
const siblings = getReactComponentSiblings(reactComponent);
// Only if the root component is wrapped in fragment, then there can be siblings
if (siblings.length > 0) {
return {
$$typeof: testSymbol,
type: "Fragment",
props: {},
children: [reactComponent, ...siblings]
.map((child) => {
return childrenReactComponentToTestObject(reactComponentToChildren(child));
})
.filter(reactFalsyValuesFilter),
};
}
return childrenReactComponentToTestObject(reactComponentToChildren(reactComponent));
}
/**
* Transform React component children into a ReactTestObject
*/
function childrenReactComponentToTestObject(childrenReactComponent) {
if (typeof childrenReactComponent !== "object") {
return childrenReactComponent;
}
return {
$$typeof: testSymbol,
type: getType(childrenReactComponent),
props: getProps(childrenReactComponent),
children: getChildrenFromProps(childrenReactComponent),
};
}
/**
* Get siblings of the react component
*/
function getReactComponentSiblings(reactComponent) {
const siblings = [];
let current = reactComponent;
while (current.sibling) {
siblings.push(current.sibling);
current = current.sibling;
}
return siblings;
}
/**
* Transform React component into a ChildrenFiberOrInternalInstance
*/
function reactComponentToChildren(reactComponent) {
if (typeof reactComponent !== "object") {
return reactComponent;
}
if (reactComponent.memoizedProps &&
typeof reactComponent.memoizedProps !== "object") {
return reactComponent.memoizedProps;
}
// React.Fragment
if (!reactComponent.elementType) {
return {
$$typeof: testSymbol,
type: "Fragment",
props: { children: reactComponent.memoizedProps },
};
}
return {
$$typeof: testSymbol,
type: reactComponent.elementType,
props: reactComponent.memoizedProps,
};
}
/**
* Check if parent of reactComponent is matching the Component
*/
function isParentComponentMatching(reactComponent, Component, componentStorage) {
if (!reactComponent.return) {
return false;
}
const { type } = reactComponent.return;
// Not sure if this can happen, but since we are dealing with internal React structures,
// it is better to be safe than sorry
if (!type) {
return false;
}
const displayName = getComponentDisplayName(type);
// Store component names for debugging purposes and better error messages
if (componentStorage && !componentStorage.includes(displayName)) {
componentStorage.push(displayName);
}
if (typeof Component === "string") {
return displayName === Component;
}
return type === Component;
}
/**
* Get display name of the react component
*/
function getComponentDisplayName(type) {
var _a;
return typeof type === "string"
? type // native elements
: type.displayName || type.name || ((_a = type.constructor) === null || _a === void 0 ? void 0 : _a.name); // functional components || class components
}
/**
* Get type of the react component
* Inspired by https://github.com/enzymejs/enzyme/blob/67b9ebeb3cc66ec1b3d43055c6463a595387fb14/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js#L888
*/
function getType(instance) {
var _a;
const { type, $$typeof } = instance;
if (reactSymbols_1.Portal && $$typeof && $$typeof === reactSymbols_1.Portal) {
return "Portal";
}
if (!type) {
throw new Error("Shallow: Unable to get type of the component. This should not happen. Please, report this issue.");
}
// Native elements
if (typeof type === "string") {
return type;
}
// Functional components
if (typeof type === "function") {
return type.displayName || type.name;
}
// Class components
if ((_a = type.prototype) === null || _a === void 0 ? void 0 : _a.isReactComponent) {
return type.constructor.name;
}
// React.memo
if (reactSymbols_1.Memo && type.$$typeof === reactSymbols_1.Memo) {
return `Memo(${getType(type)})`;
}
// React.forwardRef
if (reactSymbols_1.ForwardRef && type.$$typeof === reactSymbols_1.ForwardRef) {
return `ForwardRef(${getType({ type: type.render })})`;
}
// React.Fragment
if (reactSymbols_1.Fragment && type === reactSymbols_1.Fragment) {
return "Fragment";
}
// React.Profiler
if (reactSymbols_1.Profiler && type === reactSymbols_1.Profiler) {
return "Profiler";
}
// React.StrictMode
if (reactSymbols_1.StrictMode && type === reactSymbols_1.StrictMode) {
return "StrictMode";
}
// React.Suspense
if (reactSymbols_1.Suspense && type === reactSymbols_1.Suspense) {
return "Suspense";
}
// Unhandled type, this error hints that we need to add support for a new type
throw new Error(`Unknown type ${type}`);
}
/**
* Get props of the react component
*/
function getProps(node) {
const { props = {} } = node;
return Object.entries(props)
.filter(([key, value]) => {
// Skip children and undefined values
if (key === "children" || value === undefined) {
return false;
}
return true;
})
.reduce((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {});
}
/**
* Get children from props
*/
function getChildrenFromProps(node) {
const { children } = node.props || node;
if (!children) {
return null;
}
const arrayOfChildren = flattenNestedArrays(Array.isArray(children) ? children : [children]);
return arrayOfChildren.filter(reactFalsyValuesFilter).map((child) => {
// If child is any non-object value (number, string), return it as is
if (typeof child !== "object") {
return child;
}
return {
$$typeof: testSymbol,
type: getType(child),
props: getProps(child),
children: getChildrenFromProps(child),
};
});
}
/**
* Convert structures like `[<div />, <div />, [<div />, [<div />, <div />]]]` to `[<div />, <div />, <div />, <div />, <div />]`
*/
// biome-ignore lint/suspicious/noExplicitAny: We are very generic here, you can really pass anything
function flattenNestedArrays(array) {
return array.reduce((acc, value) => {
if (Array.isArray(value)) {
return acc.concat(flattenNestedArrays(value));
}
return acc.concat(value);
}, []);
}
/**
* Filter falsy values from React children
*/
// biome-ignore lint/suspicious/noExplicitAny: We are very generic here, you can really pass anything
function reactFalsyValuesFilter(value) {
return ![undefined, null, false].includes(value);
}