diff --git a/.gitignore b/.gitignore index a62c00612..85c971e7f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,5 +48,3 @@ packages/*/LICENSE.md packages/enzyme/README.md packages/enzyme-adapter-react-*/README.md packages/enzyme-adapter-utils*/README.md - -.npmignore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42d3e8369..c765ea353 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,6 +85,19 @@ npm run build:watch npm run test:watch ``` +Alternatively, run this in one terminal tab: +```bash +# build Enzyme locally upon save +npm run build:watch +``` + +In another terminal tab execute a specific test file for faster TDD test execution: +```bash +npx mocha packages/enzyme-test-suite/build/ReactWrapper-spec.js +``` + +NOTE that this alternate strategy may fail to rebuild some code and will bypass lint, so `npm test` will still be necessary periodically. + ### Tests for functionality shared between `shallow` and `mount` Tests for a method "foo" are stored in `packages/enzyme-test-suite/test/shared/methods/foo`. The file default exports a function that receives an injected object argument, containing the following properties: diff --git a/README.md b/README.md index 1d315c7fe..f7366f4e2 100644 --- a/README.md +++ b/README.md @@ -23,16 +23,16 @@ moving on to Enzyme v3 where React 16 is supported. To get started with enzyme, you can simply install it via npm. You will need to install enzyme along with an Adapter corresponding to the version of react (or other UI Component library) you -are using. For instance, if you are using enzyme with React 16, you can run: +are using. For instance, if you are using enzyme with React 17, you can run: ```bash -npm i --save-dev enzyme enzyme-adapter-react-16 +npm i --save-dev enzyme enzyme-adapter-react-17 ``` Each adapter may have additional peer dependencies which you will need to install as well. For instance, -`enzyme-adapter-react-16` has peer dependencies on `react` and `react-dom`. +`enzyme-adapter-react-17` has peer dependencies on `react` and `react-dom`. -At the moment, Enzyme has adapters that provide compatibility with `React 16.x`, `React 15.x`, +At the moment, Enzyme has adapters that provide compatibility with `React 17.x`, `React 16.x`, `React 15.x`, `React 0.14.x` and `React 0.13.x`. The following adapters are officially provided by enzyme, and have the following compatibility with @@ -40,6 +40,7 @@ React: | Enzyme Adapter Package | React semver compatibility | | --- | --- | +| `enzyme-adapter-react-17` | `^17.0.0-0` | | `enzyme-adapter-react-16` | `^16.4.0-0` | | `enzyme-adapter-react-16.3` | `~16.3.0-0` | | `enzyme-adapter-react-16.2` | `~16.2` | @@ -54,7 +55,7 @@ the top level `configure(...)` API. ```js import Enzyme from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; +import Adapter from 'enzyme-adapter-react-17'; Enzyme.configure({ adapter: new Adapter() }); ``` diff --git a/env.js b/env.js index 9f4171412..89644f905 100755 --- a/env.js +++ b/env.js @@ -86,6 +86,9 @@ function getAdapter(reactVersion) { return '16.1'; } } + if (semver.intersects(reactVersion, '^17.0.0')) { + return '17'; + } return null; } const reactVersion = version < 15 ? '0.' + version : version; diff --git a/install-relevant-react.sh b/install-relevant-react.sh index 1d37f8f31..fa3b9eb9d 100644 --- a/install-relevant-react.sh +++ b/install-relevant-react.sh @@ -1,6 +1,6 @@ #!/bin/sh -REACT="${REACT:-${1:-16}}" +REACT="${REACT:-${1:-17}}" echo "installing React $REACT" diff --git a/karma.conf.js b/karma.conf.js index 38ddeab18..32d396aaa 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -16,6 +16,7 @@ function getPlugins() { const adapter162 = new IgnorePlugin(/enzyme-adapter-react-16.2$/); const adapter163 = new IgnorePlugin(/enzyme-adapter-react-16.3$/); const adapter16 = new IgnorePlugin(/enzyme-adapter-react-16$/); + const adapter17 = new IgnorePlugin(/enzyme-adapter-react-17$/); var plugins = [ adapter13, @@ -23,6 +24,7 @@ function getPlugins() { adapter154, adapter15, adapter16, + adapter17, ]; function not(x) { @@ -48,6 +50,8 @@ function getPlugins() { plugins = plugins.filter(not(adapter163)); } else if (is('^16.4.0-0')) { plugins = plugins.filter(not(adapter16)); + } else if (is('^17.0.0')) { + plugins = plugins.filter(not(adapter17)); } return plugins; diff --git a/package.json b/package.json index a39f2037c..04a73d845 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "test:watch": "npm run test:only -- --watch", "pretest:karma": "npm run build", "test:karma": "karma start", - "test:all": "npm run react 13 && npm run test:only && npm run react 14 && npm run test:only && npm run react 15 && npm run test:only && npm run react 15.4 && npm run test:only && npm run react 15.5 && npm run test:only && npm run react 16 && npm run test:only && npm run react 16.1 && npm run test:only && npm run react 16.2 && npm run test:only && npm run react 16.3 && npm run test:only && npm run react 16.4 && npm run test:only && npm run react 16.5 && npm run test:only && npm run react 16.8 && npm run test:only", + "test:all": "npm run react 13 && npm run test:only && npm run react 14 && npm run test:only && npm run react 15 && npm run test:only && npm run react 15.4 && npm run test:only && npm run react 15.5 && npm run test:only && npm run react 16 && npm run test:only && npm run react 16.1 && npm run test:only && npm run react 16.2 && npm run test:only && npm run react 16.3 && npm run test:only && npm run react 16.4 && npm run test:only && npm run react 16.5 && npm run test:only && npm run react 16.8 && npm run test:only && npm run react 17 && npm run test:only", "react": "sh install-relevant-react.sh", "env:": "babel-node ./env.js", "docs:clean": "rimraf _book", diff --git a/packages/enzyme-adapter-react-17/.babelrc b/packages/enzyme-adapter-react-17/.babelrc new file mode 100644 index 000000000..ba8ef12b9 --- /dev/null +++ b/packages/enzyme-adapter-react-17/.babelrc @@ -0,0 +1,9 @@ +{ + "presets": [ + ["airbnb", { "transformRuntime": false }], + ], + "plugins": [ + ["transform-replace-object-assign", { "moduleSpecifier": "object.assign" }], + ], + "sourceMaps": "both", +} diff --git a/packages/enzyme-adapter-react-17/.eslintignore b/packages/enzyme-adapter-react-17/.eslintignore new file mode 120000 index 000000000..86039baf5 --- /dev/null +++ b/packages/enzyme-adapter-react-17/.eslintignore @@ -0,0 +1 @@ +../../.eslintignore \ No newline at end of file diff --git a/packages/enzyme-adapter-react-17/.eslintrc b/packages/enzyme-adapter-react-17/.eslintrc new file mode 100644 index 000000000..b90230db4 --- /dev/null +++ b/packages/enzyme-adapter-react-17/.eslintrc @@ -0,0 +1,22 @@ +{ + "extends": "airbnb", + "parser": "babel-eslint", + "root": true, + "rules": { + "max-classes-per-file": 0, + "max-len": 0, + "import/no-extraneous-dependencies": 2, + "import/no-unresolved": 2, + "import/extensions": 2, + "react/no-deprecated": 0, + "react/no-find-dom-node": 0, + "react/no-multi-comp": 0, + "no-underscore-dangle": 0, + "class-methods-use-this": 0 + }, + "settings": { + "react": { + "version": "17", + }, + }, +} diff --git a/packages/enzyme-adapter-react-17/.npmignore b/packages/enzyme-adapter-react-17/.npmignore new file mode 120000 index 000000000..bc62d9df1 --- /dev/null +++ b/packages/enzyme-adapter-react-17/.npmignore @@ -0,0 +1 @@ +../enzyme/.npmignore \ No newline at end of file diff --git a/packages/enzyme-adapter-react-17/.npmrc b/packages/enzyme-adapter-react-17/.npmrc new file mode 120000 index 000000000..cba44bb38 --- /dev/null +++ b/packages/enzyme-adapter-react-17/.npmrc @@ -0,0 +1 @@ +../../.npmrc \ No newline at end of file diff --git a/packages/enzyme-adapter-react-17/package.json b/packages/enzyme-adapter-react-17/package.json new file mode 100644 index 000000000..f816f58a5 --- /dev/null +++ b/packages/enzyme-adapter-react-17/package.json @@ -0,0 +1,74 @@ +{ + "name": "enzyme-adapter-react-17", + "version": "0.0.0", + "description": "JavaScript Testing utilities for React", + "homepage": "https://enzymejs.github.io/enzyme/", + "main": "build", + "scripts": { + "clean": "rimraf build", + "lint": "eslint --ext js,jsx .", + "pretest": "npm run lint", + "prebuild": "npm run clean", + "build": "babel --source-maps=both src --out-dir build", + "watch": "npm run build -- -w", + "prepublish": "not-in-publish || (npm run build && safe-publish-latest && cp ../../{LICENSE,README}.md ./)" + }, + "repository": { + "type": "git", + "url": "https://github.com/enzymejs/enzyme.git", + "directory": "packages/enzyme-adapter-react-17" + }, + "keywords": [ + "javascript", + "shallow rendering", + "shallowRender", + "test", + "reactjs", + "react", + "flux", + "testing", + "test utils", + "assertion helpers", + "tdd", + "mocha" + ], + "author": "Jordan Harband ", + "funding": { + "url": "https://github.com/sponsors/ljharb" + }, + "license": "MIT", + "dependencies": { + "enzyme-adapter-utils": "^1.13.1", + "enzyme-shallow-equal": "^1.0.4", + "has": "^1.0.3", + "object.assign": "^4.1.0", + "object.values": "^1.1.1", + "prop-types": "^15.7.2", + "react-is": "^17.0.0", + "react-reconciler": "^0.26.1", + "react-test-renderer": "^17.0.0", + "semver": "^5.7.0" + }, + "peerDependencies": { + "enzyme": "^3.0.0", + "react": "^17.0.0", + "react-dom": "^17.0.0" + }, + "devDependencies": { + "@babel/cli": "^7.0.0", + "@babel/core": "^7.0.0", + "babel-eslint": "^10.1.0", + "babel-plugin-transform-replace-object-assign": "^2.0.0", + "babel-preset-airbnb": "^4.5.0", + "enzyme": "^3.0.0", + "eslint": "^7.6.0", + "eslint-config-airbnb": "^18.2.0", + "eslint-plugin-import": "^2.22.0", + "eslint-plugin-jsx-a11y": "^6.3.1", + "eslint-plugin-react": "^7.20.5", + "eslint-plugin-react-hooks": "^4.0.8", + "in-publish": "^2.0.1", + "rimraf": "^2.7.1", + "safe-publish-latest": "^1.1.4" + } +} diff --git a/packages/enzyme-adapter-react-17/src/ReactSeventeenAdapter.js b/packages/enzyme-adapter-react-17/src/ReactSeventeenAdapter.js new file mode 100644 index 000000000..27d287140 --- /dev/null +++ b/packages/enzyme-adapter-react-17/src/ReactSeventeenAdapter.js @@ -0,0 +1,895 @@ +/* eslint no-use-before-define: 0 */ +import React from 'react'; +import ReactDOM from 'react-dom'; +// eslint-disable-next-line import/no-unresolved +import ReactDOMServer from 'react-dom/server'; +// eslint-disable-next-line import/no-unresolved +import ShallowRenderer from 'react-test-renderer/shallow'; +// eslint-disable-next-line import/no-unresolved +import TestUtils from 'react-dom/test-utils'; +import checkPropTypes from 'prop-types/checkPropTypes'; +import has from 'has'; +import { + ConcurrentMode, + ContextConsumer, + ContextProvider, + Element, + ForwardRef, + Fragment, + isContextConsumer, + isContextProvider, + isElement, + isForwardRef, + isPortal, + isSuspense, + isValidElementType, + Lazy, + Memo, + Portal, + Profiler, + StrictMode, + Suspense, +} from 'react-is'; +import { EnzymeAdapter } from 'enzyme'; +import { typeOfNode } from 'enzyme/build/Utils'; +import shallowEqual from 'enzyme-shallow-equal'; +import { + displayNameOfNode, + elementToTree as utilElementToTree, + nodeTypeFromType as utilNodeTypeFromType, + mapNativeEventNames, + propFromEvent, + assertDomAvailable, + withSetStateAllowed, + createRenderWrapper, + createMountWrapper, + propsWithKeysAndRef, + ensureKeyOrUndefined, + simulateError, + wrap, + getMaskedContext, + getComponentStack, + RootFinder, + getNodeFromRootFinder, + wrapWithWrappingComponent, + getWrappingComponentMountRenderer, + compareNodeTypeOf, +} from 'enzyme-adapter-utils'; +import findCurrentFiberUsingSlowPath from './findCurrentFiberUsingSlowPath'; +import detectFiberTags from './detectFiberTags'; + +// Lazily populated if DOM is available. +let FiberTags = null; + +function nodeAndSiblingsArray(nodeWithSibling) { + const array = []; + let node = nodeWithSibling; + while (node != null) { + array.push(node); + node = node.sibling; + } + return array; +} + +function flatten(arr) { + const result = []; + const stack = [{ i: 0, array: arr }]; + while (stack.length) { + const n = stack.pop(); + while (n.i < n.array.length) { + const el = n.array[n.i]; + n.i += 1; + if (Array.isArray(el)) { + stack.push(n); + stack.push({ i: 0, array: el }); + break; + } + result.push(el); + } + } + return result; +} + +function nodeTypeFromType(type) { + if (type === Portal) { + return 'portal'; + } + + return utilNodeTypeFromType(type); +} + +function isMemo(type) { + return compareNodeTypeOf(type, Memo); +} + +function isLazy(type) { + return compareNodeTypeOf(type, Lazy); +} + +function unmemoType(type) { + return isMemo(type) ? type.type : type; +} + +function transformSuspense(renderedEl, prerenderEl, { suspenseFallback }) { + if (!isSuspense(renderedEl)) { + return renderedEl; + } + + let { children } = renderedEl.props; + + if (suspenseFallback) { + const { fallback } = renderedEl.props; + children = replaceLazyWithFallback(children, fallback); + } + + const { + propTypes, + defaultProps, + contextTypes, + contextType, + childContextTypes, + } = renderedEl.type; + + const FakeSuspense = Object.assign( + isStateful(prerenderEl.type) + ? class FakeSuspense extends prerenderEl.type { + render() { + const { type, props } = prerenderEl; + return React.createElement( + type, + { ...props, ...this.props }, + children, + ); + } + } + : function FakeSuspense(props) { // eslint-disable-line prefer-arrow-callback + return React.createElement( + renderedEl.type, + { ...renderedEl.props, ...props }, + children, + ); + }, + { + propTypes, + defaultProps, + contextTypes, + contextType, + childContextTypes, + }, + ); + return React.createElement(FakeSuspense, null, children); +} + +function elementToTree(el) { + if (!isPortal(el)) { + return utilElementToTree(el, elementToTree); + } + + const { children, containerInfo } = el; + const props = { children, containerInfo }; + + return { + nodeType: 'portal', + type: Portal, + props, + key: ensureKeyOrUndefined(el.key), + ref: el.ref || null, + instance: null, + rendered: elementToTree(el.children), + }; +} + +function toTree(vnode) { + if (vnode == null) { + return null; + } + // TODO(lmr): I'm not really sure I understand whether or not this is what + // i should be doing, or if this is a hack for something i'm doing wrong + // somewhere else. Should talk to sebastian about this perhaps + const node = findCurrentFiberUsingSlowPath(vnode); + switch (node.tag) { + case FiberTags.HostRoot: + return childrenToTree(node.child); + case FiberTags.HostPortal: { + const { + stateNode: { containerInfo }, + memoizedProps: children, + } = node; + const props = { containerInfo, children }; + return { + nodeType: 'portal', + type: Portal, + props, + key: ensureKeyOrUndefined(node.key), + ref: node.ref, + instance: null, + rendered: childrenToTree(node.child), + }; + } + case FiberTags.ClassComponent: + return { + nodeType: 'class', + type: node.type, + props: { ...node.memoizedProps }, + key: ensureKeyOrUndefined(node.key), + ref: node.ref, + instance: node.stateNode, + rendered: childrenToTree(node.child), + }; + case FiberTags.FunctionalComponent: + return { + nodeType: 'function', + type: node.type, + props: { ...node.memoizedProps }, + key: ensureKeyOrUndefined(node.key), + ref: node.ref, + instance: null, + rendered: childrenToTree(node.child), + }; + case FiberTags.MemoClass: + return { + nodeType: 'class', + type: node.elementType.type, + props: { ...node.memoizedProps }, + key: ensureKeyOrUndefined(node.key), + ref: node.ref, + instance: node.stateNode, + rendered: childrenToTree(node.child.child), + }; + case FiberTags.MemoSFC: { + let renderedNodes = flatten(nodeAndSiblingsArray(node.child).map(toTree)); + if (node.child === null) { + renderedNodes = [null]; + } else if (renderedNodes.length === 0) { + renderedNodes = [node.memoizedProps.children]; + } + return { + nodeType: 'function', + type: node.elementType, + props: { ...node.memoizedProps }, + key: ensureKeyOrUndefined(node.key), + ref: node.ref, + instance: null, + rendered: renderedNodes, + }; + } + case FiberTags.HostComponent: { + let renderedNodes = flatten(nodeAndSiblingsArray(node.child).map(toTree)); + if (renderedNodes.length === 0) { + renderedNodes = [node.memoizedProps.children]; + } + return { + nodeType: 'host', + type: node.type, + props: { ...node.memoizedProps }, + key: ensureKeyOrUndefined(node.key), + ref: node.ref, + instance: node.stateNode, + rendered: renderedNodes, + }; + } + case FiberTags.HostText: + return node.memoizedProps; + case FiberTags.Fragment: + case FiberTags.Mode: + case FiberTags.ContextProvider: + case FiberTags.ContextConsumer: + return childrenToTree(node.child); + case FiberTags.Profiler: + case FiberTags.ForwardRef: { + return { + nodeType: 'function', + type: node.type, + props: { ...node.pendingProps }, + key: ensureKeyOrUndefined(node.key), + ref: node.ref, + instance: null, + rendered: childrenToTree(node.child), + }; + } + case FiberTags.Suspense: { + // }> creates the following Fiber tree: + // suspended: + // + // unsuspended: + // + const rendered = node.stateNode === null + ? childrenToTree(node.child.child) + // Tests explicitly want both the Suspense children and fallback if a component suspended. + // It's conceivable that testers only want to assert on the component that's rendered i.e. only fallback or only component but never both. + : [childrenToTree(node.child.child), childrenToTree(node.child.sibling)]; + return { + nodeType: 'function', + type: Suspense, + props: { ...node.memoizedProps }, + key: ensureKeyOrUndefined(node.key), + ref: node.ref, + instance: null, + rendered, + }; + } + case FiberTags.Lazy: + return childrenToTree(node.child); + case FiberTags.OffscreenComponent: { + throw new Error('Enzyme Internal Error. Encountered a Offscreen Fiber'); + } + default: + throw new Error(`Enzyme Internal Error: unknown node with tag ${node.tag}`); + } +} + +function childrenToTree(node) { + if (!node) { + return null; + } + const children = nodeAndSiblingsArray(node); + if (children.length === 0) { + return null; + } + if (children.length === 1) { + return toTree(children[0]); + } + return flatten(children.map(toTree)); +} + +function nodeToHostNode(_node) { + // NOTE(lmr): node could be a function component + // which wont have an instance prop, but we can get the + // host node associated with its return value at that point. + // Although this breaks down if the return value is an array, + // as is possible with React 16. + let node = _node; + while (node && !Array.isArray(node) && node.instance === null) { + node = node.rendered; + } + // if the SFC returned null effectively, there is no host node. + if (!node) { + return null; + } + + const mapper = (item) => { + if (item && item.instance) return ReactDOM.findDOMNode(item.instance); + return null; + }; + if (Array.isArray(node)) { + return node.map(mapper); + } + if (Array.isArray(node.rendered) && node.nodeType === 'class') { + return node.rendered.map(mapper); + } + return mapper(node); +} + +function replaceLazyWithFallback(node, fallback) { + if (!node) { + return null; + } + if (Array.isArray(node)) { + return node.map((el) => replaceLazyWithFallback(el, fallback)); + } + if (isLazy(node.type)) { + return fallback; + } + return { + ...node, + props: { + ...node.props, + children: replaceLazyWithFallback(node.props.children, fallback), + }, + }; +} + +const eventOptions = { + animation: true, + pointerEvents: true, + auxClick: true, +}; + +function wrapAct(fn) { + let returnVal; + TestUtils.act(() => { returnVal = fn(); }); + return returnVal; +} + +function getProviderDefaultValue(Provider) { + if ('_currentValue' in Provider._context) { + return Provider._context._currentValue; + } + throw new Error('Enzyme Internal Error: can’t figure out how to get Provider’s default value'); +} + +function makeFakeElement(type) { + return { $$typeof: Element, type }; +} + +function isStateful(Component) { + return Component.prototype && Component.prototype.isReactComponent; +} + +class ReactSeventeenAdapter extends EnzymeAdapter { + constructor() { + super(); + const { lifecycles } = this.options; + this.options = { + ...this.options, + enableComponentDidUpdateOnSetState: true, // TODO: remove, semver-major + legacyContextMode: 'parent', + lifecycles: { + ...lifecycles, + componentDidUpdate: { + onSetState: true, + }, + getDerivedStateFromProps: { + hasShouldComponentUpdateBug: false, + }, + getSnapshotBeforeUpdate: true, + setState: { + skipsComponentDidUpdateOnNullish: true, + }, + getChildContext: { + calledByRenderer: false, + }, + getDerivedStateFromError: true, + componentWillReceivePropsOnShallowRerender: true, + }, + }; + } + + createMountRenderer(options) { + assertDomAvailable('mount'); + if (has(options, 'suspenseFallback')) { + throw new TypeError('`suspenseFallback` is not supported by the `mount` renderer'); + } + if (FiberTags === null) { + // Requires DOM. + FiberTags = detectFiberTags(); + } + const { attachTo, hydrateIn, wrappingComponentProps } = options; + const domNode = hydrateIn || attachTo || global.document.createElement('div'); + let instance = null; + const adapter = this; + return { + render(el, context, callback) { + return wrapAct(() => { + if (instance === null) { + const { type, props, ref } = el; + const wrapperProps = { + Component: type, + props, + wrappingComponentProps, + context, + ...(ref && { refProp: ref }), + }; + const ReactWrapperComponent = createMountWrapper(el, { ...options, adapter }); + const wrappedEl = React.createElement(ReactWrapperComponent, wrapperProps); + instance = hydrateIn + ? ReactDOM.hydrate(wrappedEl, domNode) + : ReactDOM.render(wrappedEl, domNode); + if (typeof callback === 'function') { + callback(); + } + } else { + instance.setChildProps(el.props, context, callback); + } + }); + }, + unmount() { + wrapAct(() => { + ReactDOM.unmountComponentAtNode(domNode); + }); + instance = null; + }, + getNode() { + if (!instance) { + return null; + } + return getNodeFromRootFinder( + adapter.isCustomComponent, + toTree(instance._reactInternals), + options, + ); + }, + simulateError(nodeHierarchy, rootNode, error) { + const isErrorBoundary = ({ instance: elInstance, type }) => { + if (type && type.getDerivedStateFromError) { + return true; + } + return elInstance && elInstance.componentDidCatch; + }; + + const { + instance: catchingInstance, + type: catchingType, + } = nodeHierarchy.find(isErrorBoundary) || {}; + + simulateError( + error, + catchingInstance, + rootNode, + nodeHierarchy, + nodeTypeFromType, + adapter.displayNameOfNode.bind(adapter), + catchingType, + ); + }, + simulateEvent(node, event, mock) { + const mappedEvent = mapNativeEventNames(event, eventOptions); + const eventFn = TestUtils.Simulate[mappedEvent]; + if (!eventFn) { + throw new TypeError(`ReactWrapper::simulate() event '${event}' does not exist`); + } + wrapAct(() => { + eventFn(adapter.nodeToHostNode(node), mock); + }); + }, + batchedUpdates(fn) { + return fn(); + // return ReactDOM.unstable_batchedUpdates(fn); + }, + getWrappingComponentRenderer() { + return { + ...this, + ...getWrappingComponentMountRenderer({ + toTree: (inst) => toTree(inst._reactInternals), + getMountWrapperInstance: () => instance, + }), + }; + }, + wrapInvoke: wrapAct, + }; + } + + createShallowRenderer(options = {}) { + const adapter = this; + const renderer = new ShallowRenderer(); + const { suspenseFallback } = options; + if (typeof suspenseFallback !== 'undefined' && typeof suspenseFallback !== 'boolean') { + throw TypeError('`options.suspenseFallback` should be boolean or undefined'); + } + let isDOM = false; + let cachedNode = null; + + let lastComponent = null; + let wrappedComponent = null; + const sentinel = {}; + + // wrap memo components with a PureComponent, or a class component with sCU + const wrapPureComponent = (Component, compare) => { + if (lastComponent !== Component) { + if (isStateful(Component)) { + wrappedComponent = class extends Component {}; // eslint-disable-line react/prefer-stateless-function + if (compare) { + wrappedComponent.prototype.shouldComponentUpdate = (nextProps) => !compare(this.props, nextProps); + } else { + wrappedComponent.prototype.isPureReactComponent = true; + } + } else { + let memoized = sentinel; + let prevProps; + wrappedComponent = function (props, ...args) { + const shouldUpdate = memoized === sentinel || (compare + ? !compare(prevProps, props) + : !shallowEqual(prevProps, props) + ); + if (shouldUpdate) { + memoized = Component({ ...Component.defaultProps, ...props }, ...args); + prevProps = props; + } + return memoized; + }; + } + Object.assign( + wrappedComponent, + Component, + { displayName: adapter.displayNameOfNode({ type: Component }) }, + ); + lastComponent = Component; + } + return wrappedComponent; + }; + + const renderElement = (elConfig, ...rest) => { + const renderedEl = renderer.render(elConfig, ...rest); + + if (renderedEl && renderedEl.type) { + const clonedEl = transformSuspense(renderedEl, elConfig, { suspenseFallback }); + + const elementIsChanged = clonedEl.type !== renderedEl.type; + if (elementIsChanged) { + return renderer.render({ ...elConfig, type: clonedEl.type }, ...rest); + } + } + + return renderedEl; + }; + + return { + render(el, unmaskedContext, { + providerValues = new Map(), + } = {}) { + cachedNode = el; + /* eslint consistent-return: 0 */ + if (typeof el.type === 'string') { + isDOM = true; + } else if (isContextProvider(el)) { + providerValues.set(el.type, el.props.value); + const MockProvider = Object.assign( + (props) => props.children, + el.type, + ); + return withSetStateAllowed(() => renderElement({ ...el, type: MockProvider })); + } else if (isContextConsumer(el)) { + const Provider = adapter.getProviderFromConsumer(el.type); + const value = providerValues.has(Provider) + ? providerValues.get(Provider) + : getProviderDefaultValue(Provider); + const MockConsumer = Object.assign( + (props) => props.children(value), + el.type, + ); + return withSetStateAllowed(() => renderElement({ ...el, type: MockConsumer })); + } else { + isDOM = false; + let renderedEl = el; + if (isLazy(renderedEl)) { + throw TypeError('`React.lazy` is not supported by shallow rendering.'); + } + + renderedEl = transformSuspense(renderedEl, renderedEl, { suspenseFallback }); + const { type: Component } = renderedEl; + + const context = getMaskedContext(Component.contextTypes, unmaskedContext); + + if (isMemo(el.type)) { + const { type: InnerComp, compare } = el.type; + + return withSetStateAllowed(() => renderElement( + { ...el, type: wrapPureComponent(InnerComp, compare) }, + context, + )); + } + + if (!isStateful(Component) && typeof Component === 'function') { + return withSetStateAllowed(() => renderElement( + { ...renderedEl, type: Component }, + context, + )); + } + + return withSetStateAllowed(() => renderElement(renderedEl, context)); + } + }, + unmount() { + renderer.unmount(); + }, + getNode() { + if (isDOM) { + return elementToTree(cachedNode); + } + const output = renderer.getRenderOutput(); + return { + nodeType: nodeTypeFromType(cachedNode.type), + type: cachedNode.type, + props: cachedNode.props, + key: ensureKeyOrUndefined(cachedNode.key), + ref: cachedNode.ref, + instance: renderer._instance, + rendered: Array.isArray(output) + ? flatten(output).map((el) => elementToTree(el)) + : elementToTree(output), + }; + }, + simulateError(nodeHierarchy, rootNode, error) { + simulateError( + error, + renderer._instance, + cachedNode, + nodeHierarchy.concat(cachedNode), + nodeTypeFromType, + adapter.displayNameOfNode.bind(adapter), + cachedNode.type, + ); + }, + simulateEvent(node, event, ...args) { + const handler = node.props[propFromEvent(event, eventOptions)]; + if (handler) { + withSetStateAllowed(() => { + // TODO(lmr): create/use synthetic events + // TODO(lmr): emulate React's event propagation + // ReactDOM.unstable_batchedUpdates(() => { + wrapAct(() => { + handler(...args); + }); + // }); + }); + } + }, + batchedUpdates(fn) { + return fn(); + // return ReactDOM.unstable_batchedUpdates(fn); + }, + checkPropTypes(typeSpecs, values, location, hierarchy) { + return checkPropTypes( + typeSpecs, + values, + location, + displayNameOfNode(cachedNode), + () => getComponentStack(hierarchy.concat([cachedNode])), + ); + }, + }; + } + + createStringRenderer(options) { + if (has(options, 'suspenseFallback')) { + throw new TypeError('`suspenseFallback` should not be specified in options of string renderer'); + } + return { + render(el, context) { + if (options.context && (el.type.contextTypes || options.childContextTypes)) { + const childContextTypes = { + ...(el.type.contextTypes || {}), + ...options.childContextTypes, + }; + const ContextWrapper = createRenderWrapper(el, context, childContextTypes); + return ReactDOMServer.renderToStaticMarkup(React.createElement(ContextWrapper)); + } + return ReactDOMServer.renderToStaticMarkup(el); + }, + }; + } + + // Provided a bag of options, return an `EnzymeRenderer`. Some options can be implementation + // specific, like `attach` etc. for React, but not part of this interface explicitly. + // eslint-disable-next-line class-methods-use-this + createRenderer(options) { + switch (options.mode) { + case EnzymeAdapter.MODES.MOUNT: return this.createMountRenderer(options); + case EnzymeAdapter.MODES.SHALLOW: return this.createShallowRenderer(options); + case EnzymeAdapter.MODES.STRING: return this.createStringRenderer(options); + default: + throw new Error(`Enzyme Internal Error: Unrecognized mode: ${options.mode}`); + } + } + + wrap(element) { + return wrap(element); + } + + // converts an RSTNode to the corresponding JSX Pragma Element. This will be needed + // in order to implement the `Wrapper.mount()` and `Wrapper.shallow()` methods, but should + // be pretty straightforward for people to implement. + // eslint-disable-next-line class-methods-use-this + nodeToElement(node) { + if (!node || typeof node !== 'object') return null; + const { type } = node; + return React.createElement(unmemoType(type), propsWithKeysAndRef(node)); + } + + // eslint-disable-next-line class-methods-use-this + matchesElementType(node, matchingType) { + if (!node) { + return node; + } + const { type } = node; + return unmemoType(type) === unmemoType(matchingType); + } + + elementToNode(element) { + return elementToTree(element); + } + + nodeToHostNode(node, supportsArray = false) { + const nodes = nodeToHostNode(node); + if (Array.isArray(nodes) && !supportsArray) { + return nodes[0]; + } + return nodes; + } + + displayNameOfNode(node) { + if (!node) return null; + const { type, $$typeof } = node; + + const nodeType = type || $$typeof; + + // newer node types may be undefined, so only test if the nodeType exists + if (nodeType) { + switch (nodeType) { + case ConcurrentMode || NaN: return 'ConcurrentMode'; + case Fragment || NaN: return 'Fragment'; + case StrictMode || NaN: return 'StrictMode'; + case Profiler || NaN: return 'Profiler'; + case Portal || NaN: return 'Portal'; + case Suspense || NaN: return 'Suspense'; + default: + } + } + + const $$typeofType = type && type.$$typeof; + + switch ($$typeofType) { + case ContextConsumer || NaN: return 'ContextConsumer'; + case ContextProvider || NaN: return 'ContextProvider'; + case Memo || NaN: { + if (type.displayName) { + return type.displayName; + } + const name = this.displayNameOfNode({ type: type.type }); + // "works on a memoized functional component" test desires `Memo()` instead of `Memo` + return `Memo(${name})`; + } + case ForwardRef || NaN: { + if (type.displayName) { + return type.displayName; + } + const name = displayNameOfNode({ type: type.render }); + return name ? `ForwardRef(${name})` : 'ForwardRef'; + } + case Lazy || NaN: { + return 'lazy'; + } + default: return displayNameOfNode(node); + } + } + + isValidElement(element) { + return isElement(element); + } + + isValidElementType(object) { + return !!object && isValidElementType(object); + } + + isFragment(fragment) { + return typeOfNode(fragment) === Fragment; + } + + isCustomComponent(type) { + const fakeElement = makeFakeElement(type); + return !!type && ( + typeof type === 'function' + || isForwardRef(fakeElement) + || isContextProvider(fakeElement) + || isContextConsumer(fakeElement) + || isSuspense(fakeElement) + ); + } + + isContextConsumer(type) { + return !!type && isContextConsumer(makeFakeElement(type)); + } + + isCustomComponentElement(inst) { + if (!inst || !this.isValidElement(inst)) { + return false; + } + return this.isCustomComponent(inst.type); + } + + getProviderFromConsumer(Consumer) { + if (Consumer) { + let Provider; + if (Consumer._context) { + ({ Provider } = Consumer._context); + } + if (Provider) { + return Provider; + } + } + throw new Error('Enzyme Internal Error: can’t figure out how to get Provider from Consumer'); + } + + createElement(...args) { + return React.createElement(...args); + } + + wrapWithWrappingComponent(node, options) { + return { + RootFinder, + node: wrapWithWrappingComponent(React.createElement, node, options), + }; + } +} + +module.exports = ReactSeventeenAdapter; diff --git a/packages/enzyme-adapter-react-17/src/detectFiberTags.js b/packages/enzyme-adapter-react-17/src/detectFiberTags.js new file mode 100644 index 000000000..d378cf951 --- /dev/null +++ b/packages/enzyme-adapter-react-17/src/detectFiberTags.js @@ -0,0 +1,77 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { fakeDynamicImport } from 'enzyme-adapter-utils'; + +function getFiber(element) { + const container = global.document.createElement('div'); + let inst = null; + class Tester extends React.Component { + render() { + inst = this; + return element; + } + } + ReactDOM.render(React.createElement(Tester), container); + return inst._reactInternals.child; +} + +function getLazyFiber(LazyComponent) { + const container = global.document.createElement('div'); + let inst = null; + // eslint-disable-next-line react/prefer-stateless-function + class Tester extends React.Component { + render() { + inst = this; + return React.createElement(LazyComponent); + } + } + // eslint-disable-next-line react/prefer-stateless-function + class SuspenseWrapper extends React.Component { + render() { + return React.createElement( + React.Suspense, + { fallback: false }, + React.createElement(Tester), + ); + } + } + ReactDOM.render(React.createElement(SuspenseWrapper), container); + return inst._reactInternals.child; +} + +module.exports = function detectFiberTags() { + function Fn() { + return null; + } + // eslint-disable-next-line react/prefer-stateless-function + class Cls extends React.Component { + render() { + return null; + } + } + const Ctx = React.createContext(); + // React will warn if we don't have both arguments. + // eslint-disable-next-line no-unused-vars + const FwdRef = React.forwardRef((props, ref) => null); + const LazyComponent = React.lazy(() => fakeDynamicImport(() => null)); + + return { + HostRoot: getFiber('test').return.return.tag, // Go two levels above to find the root + ClassComponent: getFiber(React.createElement(Cls)).tag, + Fragment: getFiber([['nested']]).tag, + FunctionalComponent: getFiber(React.createElement(Fn)).tag, + MemoSFC: getFiber(React.createElement(React.memo(Fn))).tag, + MemoClass: getFiber(React.createElement(React.memo(Cls))).tag, + HostPortal: getFiber(ReactDOM.createPortal(null, global.document.createElement('div'))).tag, + HostComponent: getFiber(React.createElement('span')).tag, + HostText: getFiber('text').tag, + Mode: getFiber(React.createElement(React.StrictMode)).tag, + ContextConsumer: getFiber(React.createElement(Ctx.Consumer, null, () => null)).tag, + ContextProvider: getFiber(React.createElement(Ctx.Provider, { value: null }, null)).tag, + ForwardRef: getFiber(React.createElement(FwdRef)).tag, + Profiler: getFiber(React.createElement(React.Profiler, { id: 'mock', onRender() {} })).tag, + Suspense: getFiber(React.createElement(React.Suspense, { fallback: false })).tag, + Lazy: getLazyFiber(LazyComponent).tag, + OffscreenComponent: getLazyFiber('div').return.return.tag, + }; +}; diff --git a/packages/enzyme-adapter-react-17/src/findCurrentFiberUsingSlowPath.js b/packages/enzyme-adapter-react-17/src/findCurrentFiberUsingSlowPath.js new file mode 100644 index 000000000..e8d33f608 --- /dev/null +++ b/packages/enzyme-adapter-react-17/src/findCurrentFiberUsingSlowPath.js @@ -0,0 +1,104 @@ +// Extracted from https://github.com/facebook/react/blob/7bdf93b17a35a5d8fcf0ceae0bf48ed5e6b16688/src/renderers/shared/fiber/ReactFiberTreeReflection.js#L104-L228 +function findCurrentFiberUsingSlowPath(fiber) { + const { alternate } = fiber; + if (!alternate) { + return fiber; + } + // If we have two possible branches, we'll walk backwards up to the root + // to see what path the root points to. On the way we may hit one of the + // special cases and we'll deal with them. + let a = fiber; + let b = alternate; + while (true) { // eslint-disable-line + const parentA = a.return; + const parentB = parentA ? parentA.alternate : null; + if (!parentA || !parentB) { + // We're at the root. + break; + } + + // If both copies of the parent fiber point to the same child, we can + // assume that the child is current. This happens when we bailout on low + // priority: the bailed out fiber's child reuses the current child. + if (parentA.child === parentB.child) { + let { child } = parentA; + while (child) { + if (child === a) { + // We've determined that A is the current branch. + return fiber; + } + if (child === b) { + // We've determined that B is the current branch. + return alternate; + } + child = child.sibling; + } + // We should never have an alternate for any mounting node. So the only + // way this could possibly happen is if this was unmounted, if at all. + throw new Error('Unable to find node on an unmounted component.'); + } + + if (a.return !== b.return) { + // The return pointer of A and the return pointer of B point to different + // fibers. We assume that return pointers never criss-cross, so A must + // belong to the child set of A.return, and B must belong to the child + // set of B.return. + a = parentA; + b = parentB; + } else { + // The return pointers point to the same fiber. We'll have to use the + // default, slow path: scan the child sets of each parent alternate to see + // which child belongs to which set. + // + // Search parent A's child set + let didFindChild = false; + let { child } = parentA; + while (child) { + if (child === a) { + didFindChild = true; + a = parentA; + b = parentB; + break; + } + if (child === b) { + didFindChild = true; + b = parentA; + a = parentB; + break; + } + child = child.sibling; + } + if (!didFindChild) { + // Search parent B's child set + ({ child } = parentB); + while (child) { + if (child === a) { + didFindChild = true; + a = parentB; + b = parentA; + break; + } + if (child === b) { + didFindChild = true; + b = parentB; + a = parentA; + break; + } + child = child.sibling; + } + if (!didFindChild) { + throw new Error('Child was not found in either parent set. This indicates a bug ' + + 'in React related to the return pointer. Please file an issue.'); + } + } + } + } + if (a.stateNode.current === a) { + // We've determined that A is the current branch. + return fiber; + } + // Otherwise B has to be current branch. + return alternate; +} + +module.exports = findCurrentFiberUsingSlowPath; diff --git a/packages/enzyme-adapter-react-17/src/index.js b/packages/enzyme-adapter-react-17/src/index.js new file mode 100644 index 000000000..db08a6156 --- /dev/null +++ b/packages/enzyme-adapter-react-17/src/index.js @@ -0,0 +1,2 @@ +/* eslint global-require: 0 */ +module.exports = require('./ReactSeventeenAdapter'); diff --git a/packages/enzyme-adapter-react-helper/src/getAdapterForReactVersion.js b/packages/enzyme-adapter-react-helper/src/getAdapterForReactVersion.js index 9a52e72f5..b7a14fa4d 100644 --- a/packages/enzyme-adapter-react-helper/src/getAdapterForReactVersion.js +++ b/packages/enzyme-adapter-react-helper/src/getAdapterForReactVersion.js @@ -13,6 +13,9 @@ function getValidRange(version) { export default function getAdapterForReactVersion(reactVersion) { const versionRange = getValidRange(reactVersion); + if (semver.intersects(versionRange, '^17.0.0')) { + return 'enzyme-adapter-react-17'; + } if (semver.intersects(versionRange, '^16.4.0')) { return 'enzyme-adapter-react-16'; } diff --git a/packages/enzyme-adapter-react-helper/src/index.js b/packages/enzyme-adapter-react-helper/src/index.js index e0d46b97c..5f3876769 100644 --- a/packages/enzyme-adapter-react-helper/src/index.js +++ b/packages/enzyme-adapter-react-helper/src/index.js @@ -5,37 +5,42 @@ export default function setupEnzymeAdapter(enzymeOptions = {}, adapterOptions = try { // eslint-disable-next-line import/no-extraneous-dependencies, global-require, import/no-unresolved - Adapter = require('enzyme-adapter-react-16'); + Adapter = require('enzyme-adapter-react-17'); } catch (R) { try { // eslint-disable-next-line import/no-extraneous-dependencies, global-require, import/no-unresolved - Adapter = require('enzyme-adapter-react-16.3'); + Adapter = require('enzyme-adapter-react-16'); } catch (E) { try { // eslint-disable-next-line import/no-extraneous-dependencies, global-require, import/no-unresolved - Adapter = require('enzyme-adapter-react-16.2'); + Adapter = require('enzyme-adapter-react-16.3'); } catch (A) { try { // eslint-disable-next-line import/no-extraneous-dependencies, global-require, import/no-unresolved - Adapter = require('enzyme-adapter-react-16.1'); - } catch (r) { + Adapter = require('enzyme-adapter-react-16.2'); + } catch (C) { try { // eslint-disable-next-line import/no-extraneous-dependencies, global-require, import/no-unresolved - Adapter = require('enzyme-adapter-react-15'); - } catch (e) { + Adapter = require('enzyme-adapter-react-16.1'); + } catch (r) { try { // eslint-disable-next-line import/no-extraneous-dependencies, global-require, import/no-unresolved - Adapter = require('enzyme-adapter-react-15.4'); - } catch (a) { + Adapter = require('enzyme-adapter-react-15'); + } catch (e) { try { // eslint-disable-next-line import/no-extraneous-dependencies, global-require, import/no-unresolved - Adapter = require('enzyme-adapter-react-14'); - } catch (c) { + Adapter = require('enzyme-adapter-react-15.4'); + } catch (a) { try { // eslint-disable-next-line import/no-extraneous-dependencies, global-require, import/no-unresolved - Adapter = require('enzyme-adapter-react-13'); - } catch (t) { - throw new Error('It seems as though you don’t have any `enzyme-adapter-react-*` installed. Please install the relevant version and try again.'); + Adapter = require('enzyme-adapter-react-14'); + } catch (c) { + try { + // eslint-disable-next-line import/no-extraneous-dependencies, global-require, import/no-unresolved + Adapter = require('enzyme-adapter-react-13'); + } catch (t) { + throw new Error('It seems as though you don’t have any `enzyme-adapter-react-*` installed. Please install the relevant version and try again.'); + } } } } diff --git a/packages/enzyme-adapter-utils/package.json b/packages/enzyme-adapter-utils/package.json index 37188de86..0686491bb 100644 --- a/packages/enzyme-adapter-utils/package.json +++ b/packages/enzyme-adapter-utils/package.json @@ -49,7 +49,7 @@ "semver": "^6.3.1" }, "peerDependencies": { - "react": "0.13.x || 0.14.x || ^15.0.0-0 || ^16.0.0-0" + "react": "0.13.x || 0.14.x || ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0" }, "devDependencies": { "@babel/cli": "^7.19.3", diff --git a/packages/enzyme-adapter-utils/src/Utils.js b/packages/enzyme-adapter-utils/src/Utils.js index a796c29ec..297db37b2 100644 --- a/packages/enzyme-adapter-utils/src/Utils.js +++ b/packages/enzyme-adapter-utils/src/Utils.js @@ -283,6 +283,7 @@ export function getComponentStack( 'WrapperComponent', ]]); + // TODO: create proper component stack for react 17 return tuples.map(([, name], i, arr) => { const [, closestComponent] = arr.slice(i + 1).find(([nodeType]) => nodeType !== 'host') || []; return `\n in ${name}${closestComponent ? ` (created by ${closestComponent})` : ''}`; diff --git a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx index e2e3cb23d..64bab564f 100644 --- a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx @@ -406,7 +406,7 @@ describeWithDOM('mount', () => { .it('with isValidElementType defined on the Adapter', () => { expect(() => { mount(); - }).to.throw('Warning: Failed prop type: Component must be a valid element type!\n in WrapperComponent'); + }).to.throw(/^Warning: Failed prop type: Component must be a valid element type!\n {4}(?:at|in) (?:Fake\.)?WrapperComponent(?: \([^:]+:\d+:\d+\))?$/); }); }); }); diff --git a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx index efd22c9a4..0fb8d5929 100644 --- a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx @@ -1709,7 +1709,17 @@ describe('shallow', () => { describeIf(is('>= 16.6'), 'memo', () => { const App = () =>
Guest
; - const AppMemoized = memo && Object.assign(memo(App), { displayName: 'AppMemoized' }); + const AppMemoized = memo + && Object.assign( + // `React.memo` in 17 and onwards copies `memo(Component).displayName` to `Component` + // i.e. `React.memo(Component)` has a side-effect on `Component` + // So we create a new function to not pollute `SFC` since we don't want to test React behavior but Enzyme behavior + // eslint-disable-next-line prefer-arrow-callback, no-shadow + memo(function App() { + return
Guest
; + }), + { displayName: 'AppMemoized' }, + ); const RendersApp = () => ; const RendersAppMemoized = () => ; diff --git a/packages/enzyme-test-suite/test/_helpers/adapter.js b/packages/enzyme-test-suite/test/_helpers/adapter.js index 3d339c6b4..105dbf061 100644 --- a/packages/enzyme-test-suite/test/_helpers/adapter.js +++ b/packages/enzyme-test-suite/test/_helpers/adapter.js @@ -27,6 +27,8 @@ if (process.env.ADAPTER) { Adapter = require('enzyme-adapter-react-16.3'); } else if (is('^16.4.0-0')) { Adapter = require('enzyme-adapter-react-16'); +} else if (is('^17')) { + Adapter = require('enzyme-adapter-react-17'); } module.exports = Adapter; diff --git a/packages/enzyme-test-suite/test/_helpers/react-compat.js b/packages/enzyme-test-suite/test/_helpers/react-compat.js index 900ad4c0b..d3788ce7d 100644 --- a/packages/enzyme-test-suite/test/_helpers/react-compat.js +++ b/packages/enzyme-test-suite/test/_helpers/react-compat.js @@ -50,7 +50,7 @@ if (is('^0.13.0')) { ({ renderToString } = require('react-dom/server')); } -if (is('^16.0.0-0 || ^16.3.0-0')) { +if (is('^16.0.0-0 || ^16.3.0-0 || ^17.0.0')) { ({ createPortal } = require('react-dom')); } else { createPortal = null; @@ -62,13 +62,13 @@ if (is('>=15.3')) { PureComponent = null; } -if (is('^16.2.0-0')) { +if (is('^16.2.0-0 || ^17.0.0')) { ({ Fragment } = require('react')); } else { Fragment = null; } -if (is('^16.3.0-0')) { +if (is('^16.3.0-0 || ^17.0.0')) { ({ createContext, createRef, @@ -84,7 +84,7 @@ if (is('^16.3.0-0')) { AsyncMode = null; } -if (is('^16.9.0-0')) { +if (is('^16.9.0-0 || ^17.0.0')) { ({ Profiler } = require('react')); } else if (is('^16.4.0-0')) { ({ @@ -94,7 +94,7 @@ if (is('^16.9.0-0')) { Profiler = null; } -if (is('^16.6.0-0')) { +if (is('^16.6.0-0 || ^17.0.0')) { ({ Suspense, lazy, @@ -122,7 +122,7 @@ if (is('^16.9.0-0')) { createRoot = null; } -if (is('^16.8.0-0')) { +if (is('^16.8.0-0 || ^17.0.0')) { ({ useCallback, useContext, diff --git a/packages/enzyme-test-suite/test/_helpers/version.js b/packages/enzyme-test-suite/test/_helpers/version.js index fb88717f9..946288a88 100644 --- a/packages/enzyme-test-suite/test/_helpers/version.js +++ b/packages/enzyme-test-suite/test/_helpers/version.js @@ -7,11 +7,12 @@ export function is(range) { if (/&&/.test(range)) { throw new RangeError('&& may not work properly in ranges, apparently'); } - return semver.satisfies(VERSION, range); + return semver.satisfies(VERSION, range, { includePrerelease: true }); } export const REACT16 = is('16'); +export const REACT17 = is('17'); // The shallow renderer in react 16 does not yet support batched updates. When it does, // we should be able to go un-skip all of the tests that are skipped with this flag. -export const BATCHING = !REACT16; +export const BATCHING = !REACT16 && !REACT17; diff --git a/packages/enzyme-test-suite/test/enzyme-adapter-react-install-spec.js b/packages/enzyme-test-suite/test/enzyme-adapter-react-install-spec.js index 049b09ce5..3f583b7e0 100644 --- a/packages/enzyme-test-suite/test/enzyme-adapter-react-install-spec.js +++ b/packages/enzyme-test-suite/test/enzyme-adapter-react-install-spec.js @@ -3,6 +3,10 @@ import getAdapterForReactVersion from 'enzyme-adapter-react-helper/build/getAdap describe('enzyme-adapter-react-helper', () => { describe('getAdapterForReactVersion', () => { + it('returns "enzyme-adapter-react-17" when intended', () => { + expect(getAdapterForReactVersion('17.0.0')).to.equal('enzyme-adapter-react-17'); + }); + it('returns "enzyme-adapter-react-16" when intended', () => { expect(getAdapterForReactVersion('16')).to.equal('enzyme-adapter-react-16'); diff --git a/packages/enzyme-test-suite/test/shared/lifecycles/componentDidCatch.jsx b/packages/enzyme-test-suite/test/shared/lifecycles/componentDidCatch.jsx index 5cba72a4b..098ec6f78 100644 --- a/packages/enzyme-test-suite/test/shared/lifecycles/componentDidCatch.jsx +++ b/packages/enzyme-test-suite/test/shared/lifecycles/componentDidCatch.jsx @@ -219,15 +219,20 @@ export default function describeCDC({ expect(spy.args).to.be.an('array').and.have.lengthOf(1); const [[actualError, info]] = spy.args; expect(actualError).to.satisfy(properErrorMessage); - expect(info).to.deep.equal({ - componentStack: ` + if (is('>= 17')) { + expect(info).to.have.property('componentStack'); + expect(info.componentStack).to.match(/at Thrower (.+)\n/); + } else { + expect(info).to.deep.equal({ + componentStack: ` in Thrower (created by ErrorBoundary) in span (created by ErrorBoundary)${hasFragments ? '' : ` in main (created by ErrorBoundary)`} in div (created by ErrorBoundary) in ErrorBoundary (created by WrapperComponent) in WrapperComponent`, - }); + }); + } }); it('works when the root is an SFC', () => { @@ -243,8 +248,12 @@ export default function describeCDC({ expect(spy.args).to.be.an('array').and.have.lengthOf(1); const [[actualError, info]] = spy.args; expect(actualError).to.satisfy(properErrorMessage); - expect(info).to.deep.equal({ - componentStack: ` + if (is('>= 17')) { + expect(info).to.have.property('componentStack'); + expect(info.componentStack).to.match(/at Thrower (.+)\n/); + } else { + expect(info).to.deep.equal({ + componentStack: ` in Thrower (created by ErrorBoundary) in span (created by ErrorBoundary)${hasFragments ? '' : ` in main (created by ErrorBoundary)`} @@ -252,7 +261,8 @@ export default function describeCDC({ in ErrorBoundary (created by ErrorSFC) in ErrorSFC (created by WrapperComponent) in WrapperComponent`, - }); + }); + } }); }); }); diff --git a/packages/enzyme-test-suite/test/shared/lifecycles/misc.jsx b/packages/enzyme-test-suite/test/shared/lifecycles/misc.jsx index 86a35f0ab..d25598782 100644 --- a/packages/enzyme-test-suite/test/shared/lifecycles/misc.jsx +++ b/packages/enzyme-test-suite/test/shared/lifecycles/misc.jsx @@ -491,7 +491,12 @@ export default function describeMisc({ const [name, error, info] = fourth; expect(name).to.equal('componentDidCatch'); expect(error).to.satisfy(properErrorMessage); - expect(info).to.deep.equal(expectedInfo); + if (is('>= 17')) { + expect(info).to.have.property('componentStack'); + expect(info.componentStack).to.match(/at Thrower (.+)\n/); + } else { + expect(info).to.deep.equal(expectedInfo); + } expect(stateSpy.args).to.deep.equal([ [{ diff --git a/packages/enzyme-test-suite/test/shared/methods/debug.jsx b/packages/enzyme-test-suite/test/shared/methods/debug.jsx index 928f69101..7d3594c9e 100644 --- a/packages/enzyme-test-suite/test/shared/methods/debug.jsx +++ b/packages/enzyme-test-suite/test/shared/methods/debug.jsx @@ -81,12 +81,29 @@ export default function describeDebug({ const SFCMemo = memo && memo(SFC); const SFCwithDisplayNameMemo = memo && memo(SFCwithDisplayName); - const SFCMemoWithDisplayName = memo && Object.assign(memo(SFC), { + // `React.memo` in 17 and onwards copies `memo(Component).displayName` to `Component` + // i.e. `React.memo(Component)` has a side-effect on `Component` + // So we create a new function to not pollute `SFC` since we don't want to test React behavior but Enzyme behavior + // eslint-disable-next-line prefer-arrow-callback, no-shadow + const SFCMemoWithDisplayName = memo && Object.assign(memo(function SFC() { return null; }), { displayName: 'SFCMemoWithDisplayName!', }); - const SFCMemoWitDoubleDisplayName = memo && Object.assign(memo(SFCwithDisplayName), { - displayName: 'SFCMemoWitDoubleDisplayName!', - }); + const SFCMemoWitDoubleDisplayName = memo + && Object.assign( + memo( + Object.assign( + // `React.memo` in 17 and onwards copies `memo(Component).displayName` to `Component` + // i.e. `React.memo(Component)` has a side-effect on `Component` + // So we create a new function to not pollute `SFC` since we don't want to test React behavior but Enzyme behavior + // eslint-disable-next-line prefer-arrow-callback, no-shadow + function SFCwithDisplayName() { return null; }, + { displayName: 'SFC!' }, + ), + ), + { + displayName: 'SFCMemoWitDoubleDisplayName!', + }, + ); it('displays the expected display names', () => { expect(SFCMemoWithDisplayName).to.have.property('displayName'); @@ -100,6 +117,7 @@ export default function describeDebug({ )); + expect(wrapper.debug()).to.equal(`
diff --git a/packages/enzyme-test-suite/test/shared/methods/simulate.jsx b/packages/enzyme-test-suite/test/shared/methods/simulate.jsx index a40874e43..b3a62e9a8 100644 --- a/packages/enzyme-test-suite/test/shared/methods/simulate.jsx +++ b/packages/enzyme-test-suite/test/shared/methods/simulate.jsx @@ -250,8 +250,20 @@ export default function describeSimulate({ const wrapper = Wrap(); wrapper.simulate('click'); - expect(wrapper.text()).to.equal('1'); - expect(renderCount).to.equal(2); + + if (is('>= 17') && isShallow) { + // Something changed in 17 so that calling an event handler (like onClick, above) directly, + // as enzyme's simulate() does under shallow(), does not batch setState calls. Using enzyme's + // simulate() under mount() still batches setState as expected, probably + // because enzyme uses ReactTestUtils.Simulate() to trigger event handlers under mount(). + // See the two simulateEvent() methods in packages/enzyme-adapter-react-17/src/ReactSeventeenAdapter.js + // for more info + expect(wrapper.text()).to.equal('2'); + expect(renderCount).to.equal(3); + } else { + expect(wrapper.text()).to.equal('1'); + expect(renderCount).to.equal(2); + } }); // FIXME: figure out why this fails on 15.0 and 15.1 diff --git a/packages/enzyme/src/ShallowWrapper.js b/packages/enzyme/src/ShallowWrapper.js index 723683d10..c61e95d40 100644 --- a/packages/enzyme/src/ShallowWrapper.js +++ b/packages/enzyme/src/ShallowWrapper.js @@ -635,6 +635,20 @@ class ShallowWrapper { instance.state, ); } + if ( + shouldRender + && instance + && context + ) { + if (lifecycles.componentWillReceivePropsOnShallowRerender) { + if (typeof instance.componentWillReceiveProps === 'function') { + instance.componentWillReceiveProps(props); + } + if (typeof instance.UNSAFE_componentWillReceiveProps === 'function') { // eslint-disable-line new-cap + instance.UNSAFE_componentWillReceiveProps(props); // eslint-disable-line new-cap + } + } + } if (props) this[UNRENDERED] = cloneElement(adapter, this[UNRENDERED], props); this[RENDERER].render(this[UNRENDERED], nextContext, { providerValues: this[PROVIDER_VALUES],