mirror of
https://github.com/cypress-io/cypress.git
synced 2026-02-20 06:01:12 -06:00
* perf: improve performance of `experimentalSourceRewriting` This change is in addition to https://github.com/benjamn/recast/pull/1399. This commit focuses on Cypress' use of recast, with the following optimizations: 1. Avoid printing the source file again if no change was made. Printing an AST using recast does reuse the original text, but identifying for which parts of the AST reuse is possible comes with noticeable overhead. By tracking changes within the visitor it becomes possible to skip this phase entirely if no changes were made. 2. Avoid a scope lookup (`path.scope.declares(node.name)`) for all identifiers in the program, doing it only for identifiers that may have to be rewritten. With these changes, a 2.6MB bundle that does not need rewriting has improved from 4.4s to 2.3s, provided that the above-mentioned recast PR has been applied. * chore: move `experimentalSourceRewriting` release note to pending release section --------- Co-authored-by: Bill Glesias <bglesias@gmail.com> Co-authored-by: Jennifer Shehane <jennifer@cypress.io>
204 lines
6.0 KiB
TypeScript
204 lines
6.0 KiB
TypeScript
import {
|
|
Visitor,
|
|
namedTypes as n,
|
|
builders as b,
|
|
} from 'ast-types'
|
|
import type { ExpressionKind } from 'ast-types/gen/kinds'
|
|
|
|
// use `globalThis` instead of `window`, `self`... to lower chances of scope conflict
|
|
// users can technically override even this, but it would be very rude
|
|
// "[globalThis] provides a way for polyfills/shims, build tools, and portable code to have a reliable non-eval means to access the global..."
|
|
// @see https://github.com/tc39/proposal-global/blob/master/NAMING.md
|
|
const globalIdentifier = b.identifier('globalThis')
|
|
|
|
/**
|
|
* Generate a CallExpression for Cypress.resolveWindowReference
|
|
* @param accessedObject object being accessed
|
|
* @param prop name of property being accessed
|
|
* @param maybeVal if an assignment is being made, this is the RHS of the assignment
|
|
*/
|
|
function resolveWindowReference (accessedObject: ExpressionKind, prop: string, maybeVal?: ExpressionKind) {
|
|
const args = [
|
|
globalIdentifier,
|
|
accessedObject,
|
|
b.stringLiteral(prop),
|
|
]
|
|
|
|
if (maybeVal) {
|
|
args.push(maybeVal)
|
|
}
|
|
|
|
return b.callExpression(
|
|
b.memberExpression(
|
|
b.memberExpression(
|
|
b.memberExpression(
|
|
globalIdentifier,
|
|
b.identifier('top'),
|
|
),
|
|
b.identifier('Cypress'),
|
|
),
|
|
b.identifier('resolveWindowReference'),
|
|
),
|
|
args,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Generate a CallExpression for Cypress.resolveLocationReference
|
|
*/
|
|
function resolveLocationReference () {
|
|
return b.callExpression(
|
|
b.memberExpression(
|
|
b.memberExpression(
|
|
b.memberExpression(
|
|
globalIdentifier,
|
|
b.identifier('top'),
|
|
),
|
|
b.identifier('Cypress'),
|
|
),
|
|
b.identifier('resolveLocationReference'),
|
|
),
|
|
[globalIdentifier],
|
|
)
|
|
}
|
|
|
|
const replaceableProps = ['parent', 'top', 'location']
|
|
|
|
/**
|
|
* Given an Identifier or a Literal, return a property name that should use `resolveWindowReference`.
|
|
* @param node
|
|
*/
|
|
function getReplaceablePropOfMemberExpression (node: n.MemberExpression) {
|
|
const { property } = node
|
|
|
|
// something.(top|parent)
|
|
if (n.Identifier.check(property) && replaceableProps.includes(property.name)) {
|
|
return property.name
|
|
}
|
|
|
|
// something['(top|parent)']
|
|
if (n.Literal.check(property) && replaceableProps.includes(String(property.value))) {
|
|
return String(property.value)
|
|
}
|
|
|
|
// NOTE: cases where a variable is used for the prop will not be replaced
|
|
// for example, `bar = 'top'; window[bar];` will not be replaced
|
|
// this would most likely be too slow
|
|
|
|
return
|
|
}
|
|
|
|
/**
|
|
* An AST Visitor that applies JS transformations required for Cypress.
|
|
* @see https://github.com/benjamn/ast-types#ast-traversal for details on how the Visitor is implemented
|
|
* @see https://astexplorer.net/#/gist/7f1e645c74df845b0e1f814454e9bbdf/f443b701b53bf17fbbf40e9285cb8b65a4066240
|
|
* to explore ASTs generated by recast
|
|
*/
|
|
export const jsRules: Visitor<{}> = {
|
|
// replace member accesses like foo['top'] or bar.parent with resolveWindowReference
|
|
visitMemberExpression (path) {
|
|
const { node } = path
|
|
|
|
const prop = getReplaceablePropOfMemberExpression(node)
|
|
|
|
if (!prop) {
|
|
return this.traverse(path)
|
|
}
|
|
|
|
path.replace(resolveWindowReference(path.get('object').node, prop))
|
|
this.reportChanged()
|
|
|
|
return false
|
|
},
|
|
// replace lone identifiers like `top`, `parent`, with resolveWindowReference
|
|
visitIdentifier (path) {
|
|
const { node } = path
|
|
|
|
if (path.parentPath) {
|
|
const parentNode = path.parentPath.node
|
|
|
|
// like `identifier = 'foo'`
|
|
const isAssignee = n.AssignmentExpression.check(parentNode) && parentNode.left === node
|
|
|
|
if (isAssignee && node.name === 'location') {
|
|
// `location = 'something'`, rewrite to intercepted href setter since relative urls can break this
|
|
path.replace(b.memberExpression(resolveLocationReference(), b.identifier('href')))
|
|
this.reportChanged()
|
|
|
|
return false
|
|
}
|
|
|
|
// some Identifiers do not refer to a scoped variable, depending on how they're used
|
|
if (
|
|
// like `var top = 'foo'`
|
|
(n.VariableDeclarator.check(parentNode) && parentNode.id === node)
|
|
|| (isAssignee)
|
|
|| (
|
|
[
|
|
'LabeledStatement', // like `top: foo();`
|
|
'ContinueStatement', // like 'continue top'
|
|
'BreakStatement', // like 'break top'
|
|
'Property', // like `{ top: 'foo' }`
|
|
'FunctionDeclaration', // like `function top()`
|
|
'RestElement', // like (...top)
|
|
'ArrowFunctionExpression', // like `(top, ...parent) => { }`
|
|
'ArrowExpression', // MDN Parser docs mention this being used for () => {}
|
|
'FunctionExpression', // like `(function top())`,
|
|
].includes(parentNode.type)
|
|
)
|
|
) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// identifier has been declared in local scope, don't care about replacing
|
|
if (!replaceableProps.includes(node.name) || path.scope.declares(node.name)) {
|
|
return this.traverse(path)
|
|
}
|
|
|
|
switch (node.name) {
|
|
case 'location':
|
|
path.replace(resolveLocationReference())
|
|
this.reportChanged()
|
|
|
|
return false
|
|
case 'parent':
|
|
case 'top':
|
|
path.replace(resolveWindowReference(globalIdentifier, node.name))
|
|
this.reportChanged()
|
|
|
|
return false
|
|
default:
|
|
return this.traverse(path)
|
|
}
|
|
},
|
|
visitAssignmentExpression (path) {
|
|
const { node } = path
|
|
|
|
if (!n.MemberExpression.check(node.left)) {
|
|
return this.traverse(path)
|
|
}
|
|
|
|
const propBeingSet = getReplaceablePropOfMemberExpression(node.left)
|
|
|
|
if (!propBeingSet) {
|
|
return this.traverse(path)
|
|
}
|
|
|
|
if (node.operator !== '=') {
|
|
// in the case of +=, -=, |=, etc., assume they're not doing something like
|
|
// `window.top += 4` since that would be invalid anyways, just continue down the RHS
|
|
this.traverse(path.get('right'))
|
|
|
|
return false
|
|
}
|
|
|
|
const objBeingSetOn = node.left.object
|
|
|
|
path.replace(resolveWindowReference(objBeingSetOn, propBeingSet, node.right))
|
|
this.reportChanged()
|
|
|
|
return false
|
|
},
|
|
}
|