Files
cypress/packages/rewriter/lib/js-rules.ts
Joost Koehoorn a6b58a8b2f perf: improve performance of experimentalSourceRewriting (#29540)
* 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>
2024-06-21 15:24:42 -04:00

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
},
}