Skip to content

Commit f0d3a48

Browse files
authored
feat: React 18 support (#22876)
* update cli exports * add additional react adapters * update system test infra to better cover react versions * use idiomatic cy.mount and cy.unmount * add additional test projects * update tests * add new modules * remove dead code, organize code more * add react 16 project * update webpack to resolve react correctly * add test for react 16 * update snaps * add react adapters * ignore cli/react files * use official rollup plugin to bundle npm/react * update yarn lock for webpack dev server tests * update vite dev server projects * update config * remove console.log * update tsconfig * fix tests * fix another test * update snaps * update snaps * fix build * remove react{16,17}, update tests * update build * add missing export * update test * fixing tests * fixing tests * update snaps * update snaps again * build artifacts on circle * dont try to update rollup plugin * update circle * update * add missing build step * update deps, remove old code * revert circle changes * do not hoist deps from react18 * remove deps
1 parent 60fd568 commit f0d3a48

File tree

73 files changed

+7933
-554
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

73 files changed

+7933
-554
lines changed

cli/.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,5 @@ build
1717
# the sync-exported-npm-with-cli.js script
1818
vue
1919
vue2
20-
react
20+
react*
2121
mount-utils

cli/package.json

+7-1
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@
107107
"mount-utils",
108108
"vue",
109109
"react",
110-
"vue2"
110+
"vue2",
111+
"react18"
111112
],
112113
"bin": {
113114
"cypress": "bin/cypress"
@@ -141,6 +142,11 @@
141142
"require": "./react/dist/cypress-react.cjs.js",
142143
"types": "./react/dist/index.d.ts"
143144
},
145+
"./react18": {
146+
"import": "./react18/dist/cypress-react.esm-bundler.js",
147+
"require": "./react18/dist/cypress-react.cjs.js",
148+
"types": "./react18/dist/index.d.ts"
149+
},
144150
"./mount-utils": {
145151
"require": "./mount-utils/dist/index.js",
146152
"types": "./mount-utils/dist/index.d.ts"

cli/scripts/post-build.js

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ shell.set('-e') // any error is fatal
99
const npmModulesToCopy = [
1010
'mount-utils',
1111
'react',
12+
'react18',
1213
'vue',
1314
'vue2',
1415
]

npm/react/rollup.config.js

+24-28
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import ts from 'rollup-plugin-typescript2'
2-
import resolve from '@rollup/plugin-node-resolve'
3-
import commonjs from '@rollup/plugin-commonjs'
1+
// CommonJS to easily share across packages
2+
const ts = require('rollup-plugin-typescript2')
3+
const { default: resolve } = require('@rollup/plugin-node-resolve')
4+
const commonjs = require('@rollup/plugin-commonjs')
45

5-
import pkg from './package.json'
6+
const pkg = require('./package.json')
67

78
const banner = `
89
/**
@@ -16,17 +17,29 @@ function createEntry (options) {
1617
const {
1718
format,
1819
input,
19-
isBrowser,
2020
} = options
2121

2222
const config = {
2323
input,
2424
external: [
2525
'react',
2626
'react-dom',
27+
'react-dom/client',
2728
],
2829
plugins: [
29-
resolve(), commonjs(),
30+
resolve(),
31+
commonjs(),
32+
ts({
33+
check: format === 'es',
34+
tsconfigOverride: {
35+
compilerOptions: {
36+
declaration: format === 'es',
37+
target: 'es5',
38+
module: format === 'cjs' ? 'es2015' : 'esnext',
39+
},
40+
exclude: ['tests'],
41+
},
42+
}),
3043
],
3144
output: {
3245
banner,
@@ -36,43 +49,26 @@ function createEntry (options) {
3649
globals: {
3750
react: 'React',
3851
'react-dom': 'ReactDOM',
52+
'react-dom/client': 'ReactDOM/client',
3953
},
4054
},
4155
}
4256

4357
if (format === 'es') {
4458
config.output.file = pkg.module
45-
if (isBrowser) {
46-
config.output.file = pkg.unpkg
47-
}
4859
}
4960

5061
if (format === 'cjs') {
5162
config.output.file = pkg.main
5263
}
5364

65+
// eslint-disable-next-line no-console
5466
console.log(`Building ${format}: ${config.output.file}`)
5567

56-
config.plugins.push(
57-
ts({
58-
check: format === 'es' && isBrowser,
59-
tsconfigOverride: {
60-
compilerOptions: {
61-
declaration: format === 'es',
62-
target: 'es5', // not sure what this should be?
63-
module: format === 'cjs' ? 'es2015' : 'esnext',
64-
},
65-
exclude: ['tests'],
66-
},
67-
}),
68-
)
69-
7068
return config
7169
}
7270

73-
export default [
74-
createEntry({ format: 'es', input: 'src/index.ts', isBrowser: false }),
75-
createEntry({ format: 'es', input: 'src/index.ts', isBrowser: true }),
76-
createEntry({ format: 'iife', input: 'src/index.ts', isBrowser: true }),
77-
createEntry({ format: 'cjs', input: 'src/index.ts', isBrowser: false }),
71+
module.exports = [
72+
createEntry({ format: 'es', input: 'src/index.ts' }),
73+
createEntry({ format: 'cjs', input: 'src/index.ts' }),
7874
]

npm/react/src/createMount.ts

+207
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import * as React from 'react'
2+
import ReactDOM from 'react-dom'
3+
import getDisplayName from './getDisplayName'
4+
import {
5+
injectStylesBeforeElement,
6+
getContainerEl,
7+
ROOT_SELECTOR,
8+
setupHooks,
9+
} from '@cypress/mount-utils'
10+
import type { InternalMountOptions, InternalUnmountOptions, MountOptions, MountReturn, UnmountArgs } from './types'
11+
12+
/**
13+
* Inject custom style text or CSS file or 3rd party style resources
14+
*/
15+
const injectStyles = (options: MountOptions) => {
16+
return (): HTMLElement => {
17+
const el = getContainerEl()
18+
19+
return injectStylesBeforeElement(options, document, el)
20+
}
21+
}
22+
23+
export let lastMountedReactDom: (typeof ReactDOM) | undefined
24+
25+
/**
26+
* Create an `mount` function. Performs all the non-React-version specific
27+
* behavior related to mounting. The React-version-specific code
28+
* is injected. This helps us to maintain a consistent public API
29+
* and handle breaking changes in React's rendering API.
30+
*
31+
* This is designed to be consumed by `npm/react{16,17,18}`, and other React adapters,
32+
* or people writing adapters for third-party, custom adapters.
33+
*/
34+
export const makeMountFn = (
35+
type: 'mount' | 'rerender',
36+
jsx: React.ReactNode,
37+
options: MountOptions = {},
38+
rerenderKey?: string,
39+
internalMountOptions?: InternalMountOptions,
40+
): globalThis.Cypress.Chainable<MountReturn> => {
41+
if (!internalMountOptions) {
42+
throw Error('internalMountOptions must be provided with `render` and `reactDom` parameters')
43+
}
44+
45+
// Get the display name property via the component constructor
46+
// @ts-ignore FIXME
47+
const componentName = getDisplayName(jsx.type, options.alias)
48+
const displayName = options.alias || componentName
49+
50+
const jsxComponentName = `<${componentName} ... />`
51+
52+
const message = options.alias
53+
? `${jsxComponentName} as "${options.alias}"`
54+
: jsxComponentName
55+
56+
return cy
57+
.then(injectStyles(options))
58+
.then(() => {
59+
const reactDomToUse = internalMountOptions.reactDom
60+
61+
lastMountedReactDom = reactDomToUse
62+
63+
const el = getContainerEl()
64+
65+
if (!el) {
66+
throw new Error(
67+
[
68+
`[@cypress/react] 🔥 Hmm, cannot find root element to mount the component. Searched for ${ROOT_SELECTOR}`,
69+
].join(' '),
70+
)
71+
}
72+
73+
const key = rerenderKey ??
74+
// @ts-ignore provide unique key to the the wrapped component to make sure we are rerendering between tests
75+
(Cypress?.mocha?.getRunner()?.test?.title as string || '') + Math.random()
76+
const props = {
77+
key,
78+
}
79+
80+
const reactComponent = React.createElement(
81+
options.strict ? React.StrictMode : React.Fragment,
82+
props,
83+
jsx,
84+
)
85+
// since we always surround the component with a fragment
86+
// let's get back the original component
87+
const userComponent = (reactComponent.props as {
88+
key: string
89+
children: React.ReactNode
90+
}).children
91+
92+
internalMountOptions.render(reactComponent, el, reactDomToUse)
93+
94+
if (options.log !== false) {
95+
Cypress.log({
96+
name: type,
97+
type: 'parent',
98+
message: [message],
99+
// @ts-ignore
100+
$el: (el.children.item(0) as unknown) as JQuery<HTMLElement>,
101+
consoleProps: () => {
102+
return {
103+
// @ts-ignore protect the use of jsx functional components use ReactNode
104+
props: jsx.props,
105+
description: type === 'mount' ? 'Mounts React component' : 'Rerenders mounted React component',
106+
home: 'https://github.com/cypress-io/cypress',
107+
}
108+
},
109+
}).snapshot('mounted').end()
110+
}
111+
112+
return (
113+
// Separate alias and returned value. Alias returns the component only, and the thenable returns the additional functions
114+
cy.wrap<React.ReactNode>(userComponent, { log: false })
115+
.as(displayName)
116+
.then(() => {
117+
return cy.wrap<MountReturn>({
118+
component: userComponent,
119+
rerender: (newComponent) => makeMountFn('rerender', newComponent, options, key, internalMountOptions),
120+
unmount: internalMountOptions.unmount,
121+
}, { log: false })
122+
})
123+
// by waiting, we delaying test execution for the next tick of event loop
124+
// and letting hooks and component lifecycle methods to execute mount
125+
// https://github.com/bahmutov/cypress-react-unit-test/issues/200
126+
.wait(0, { log: false })
127+
)
128+
// Bluebird types are terrible. I don't think the return type can be carried without this cast
129+
}) as unknown as globalThis.Cypress.Chainable<MountReturn>
130+
}
131+
132+
/**
133+
* Create an `unmount` function. Performs all the non-React-version specific
134+
* behavior related to unmounting.
135+
*
136+
* This is designed to be consumed by `npm/react{16,17,18}`, and other React adapters,
137+
* or people writing adapters for third-party, custom adapters.
138+
*/
139+
export const makeUnmountFn = (options: UnmountArgs, internalUnmountOptions: InternalUnmountOptions) => {
140+
return cy.then(() => {
141+
return cy.get(ROOT_SELECTOR, { log: false }).then(($el) => {
142+
if (lastMountedReactDom) {
143+
internalUnmountOptions.unmount($el[0])
144+
const wasUnmounted = internalUnmountOptions.unmount($el[0])
145+
146+
if (wasUnmounted && options.log) {
147+
Cypress.log({
148+
name: 'unmount',
149+
type: 'parent',
150+
message: [options.boundComponentMessage ?? 'Unmounted component'],
151+
consoleProps: () => {
152+
return {
153+
description: 'Unmounts React component',
154+
parent: $el[0],
155+
home: 'https://github.com/cypress-io/cypress',
156+
}
157+
},
158+
})
159+
}
160+
}
161+
})
162+
})
163+
}
164+
165+
// Cleanup before each run
166+
// NOTE: we cannot use unmount here because
167+
// we are not in the context of a test
168+
const preMountCleanup = () => {
169+
const el = getContainerEl()
170+
171+
if (el && lastMountedReactDom) {
172+
lastMountedReactDom.unmountComponentAtNode(el)
173+
}
174+
}
175+
176+
const _mount = (jsx: React.ReactNode, options: MountOptions = {}) => makeMountFn('mount', jsx, options)
177+
178+
export const createMount = (defaultOptions: MountOptions) => {
179+
return (
180+
element: React.ReactElement,
181+
options?: MountOptions,
182+
) => {
183+
return _mount(element, { ...defaultOptions, ...options })
184+
}
185+
}
186+
187+
/** @deprecated Should be removed in the next major version */
188+
// TODO: Remove
189+
export default _mount
190+
191+
export interface JSX extends Function {
192+
displayName: string
193+
}
194+
195+
// Side effects from "import { mount } from '@cypress/<my-framework>'" are annoying, we should avoid doing this
196+
// by creating an explicit function/import that the user can register in their 'component.js' support file,
197+
// such as:
198+
// import 'cypress/<my-framework>/support'
199+
// or
200+
// import { registerCT } from 'cypress/<my-framework>'
201+
// registerCT()
202+
// Note: This would be a breaking change
203+
204+
// it is required to unmount component in beforeEach hook in order to provide a clean state inside test
205+
// because `mount` can be called after some preparation that can side effect unmount
206+
// @see npm/react/cypress/component/advanced/set-timeout-example/loading-indicator-spec.js
207+
setupHooks(preMountCleanup)

npm/react/src/getDisplayName.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { JSX } from './mount'
1+
import { JSX } from './createMount'
22

33
const cachedDisplayNames: WeakMap<JSX, string> = new WeakMap()
44

npm/react/src/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
export * from './createMount'
2+
13
export * from './mount'
24

35
export * from './mountHook'
6+
7+
export * from './types'

0 commit comments

Comments
 (0)