diff --git a/src/ast/Entity.ts b/src/ast/Entity.ts index 4447be9f109..7ab643b7008 100644 --- a/src/ast/Entity.ts +++ b/src/ast/Entity.ts @@ -12,5 +12,6 @@ export interface WritableEntity extends Entity { * expression of this node is reassigned as well. */ deoptimizePath(path: ObjectPath): void; + hasEffectsWhenAssignedAtPath(path: ObjectPath, context: HasEffectsContext): boolean; } diff --git a/src/ast/nodes/ArrowFunctionExpression.ts b/src/ast/nodes/ArrowFunctionExpression.ts index 7a6a51522cc..9f90349b429 100644 --- a/src/ast/nodes/ArrowFunctionExpression.ts +++ b/src/ast/nodes/ArrowFunctionExpression.ts @@ -1,104 +1,40 @@ -import type { NormalizedTreeshakingOptions } from '../../rollup/types'; -import { type CallOptions, NO_ARGS } from '../CallOptions'; -import { - BROKEN_FLOW_NONE, - type HasEffectsContext, - type InclusionContext -} from '../ExecutionContext'; +import { type CallOptions } from '../CallOptions'; +import { type HasEffectsContext, InclusionContext } from '../ExecutionContext'; import ReturnValueScope from '../scopes/ReturnValueScope'; import type Scope from '../scopes/Scope'; -import { type ObjectPath, UNKNOWN_PATH, UnknownKey } from '../utils/PathTracker'; +import { type ObjectPath } from '../utils/PathTracker'; import BlockStatement from './BlockStatement'; import Identifier from './Identifier'; import * as NodeType from './NodeType'; -import RestElement from './RestElement'; -import type SpreadElement from './SpreadElement'; -import { type ExpressionEntity, UNKNOWN_EXPRESSION } from './shared/Expression'; -import { - type ExpressionNode, - type GenericEsTreeNode, - type IncludeChildren, - NodeBase -} from './shared/Node'; +import FunctionBase from './shared/FunctionBase'; +import { type ExpressionNode, IncludeChildren } from './shared/Node'; +import { ObjectEntity } from './shared/ObjectEntity'; +import { OBJECT_PROTOTYPE } from './shared/ObjectPrototype'; import type { PatternNode } from './shared/Pattern'; -export default class ArrowFunctionExpression extends NodeBase { +export default class ArrowFunctionExpression extends FunctionBase { declare async: boolean; declare body: BlockStatement | ExpressionNode; declare params: readonly PatternNode[]; declare preventChildBlockScope: true; declare scope: ReturnValueScope; declare type: NodeType.tArrowFunctionExpression; - private deoptimizedReturn = false; + protected objectEntity: ObjectEntity | null = null; createScope(parentScope: Scope): void { this.scope = new ReturnValueScope(parentScope, this.context); } - deoptimizePath(path: ObjectPath): void { - // A reassignment of UNKNOWN_PATH is considered equivalent to having lost track - // which means the return expression needs to be reassigned - if (path.length === 1 && path[0] === UnknownKey) { - this.scope.getReturnExpression().deoptimizePath(UNKNOWN_PATH); - } - } - - // Arrow functions do not mutate their context - deoptimizeThisOnEventAtPath(): void {} - - getReturnExpressionWhenCalledAtPath(path: ObjectPath): ExpressionEntity { - if (path.length !== 0) { - return UNKNOWN_EXPRESSION; - } - if (this.async) { - if (!this.deoptimizedReturn) { - this.deoptimizedReturn = true; - this.scope.getReturnExpression().deoptimizePath(UNKNOWN_PATH); - this.context.requestTreeshakingPass(); - } - return UNKNOWN_EXPRESSION; - } - return this.scope.getReturnExpression(); - } - hasEffects(): boolean { return false; } - hasEffectsWhenAccessedAtPath(path: ObjectPath): boolean { - return path.length > 1; - } - - hasEffectsWhenAssignedAtPath(path: ObjectPath): boolean { - return path.length > 1; - } - hasEffectsWhenCalledAtPath( path: ObjectPath, - _callOptions: CallOptions, + callOptions: CallOptions, context: HasEffectsContext ): boolean { - if (path.length > 0) return true; - if (this.async) { - const { propertyReadSideEffects } = this.context.options - .treeshake as NormalizedTreeshakingOptions; - const returnExpression = this.scope.getReturnExpression(); - if ( - returnExpression.hasEffectsWhenCalledAtPath( - ['then'], - { args: NO_ARGS, thisParam: null, withNew: false }, - context - ) || - (propertyReadSideEffects && - (propertyReadSideEffects === 'always' || - returnExpression.hasEffectsWhenAccessedAtPath(['then'], context))) - ) { - return true; - } - } - for (const param of this.params) { - if (param.hasEffects(context)) return true; - } + if (super.hasEffectsWhenCalledAtPath(path, callOptions, context)) return true; const { ignore, brokenFlow } = context; context.ignore = { breaks: false, @@ -113,43 +49,18 @@ export default class ArrowFunctionExpression extends NodeBase { } include(context: InclusionContext, includeChildrenRecursively: IncludeChildren): void { - this.included = true; + super.include(context, includeChildrenRecursively); for (const param of this.params) { if (!(param instanceof Identifier)) { param.include(context, includeChildrenRecursively); } } - const { brokenFlow } = context; - context.brokenFlow = BROKEN_FLOW_NONE; - this.body.include(context, includeChildrenRecursively); - context.brokenFlow = brokenFlow; - } - - includeCallArguments( - context: InclusionContext, - args: readonly (ExpressionNode | SpreadElement)[] - ): void { - this.scope.includeCallArguments(context, args); - } - - initialise(): void { - this.scope.addParameterVariables( - this.params.map(param => param.declare('parameter', UNKNOWN_EXPRESSION)), - this.params[this.params.length - 1] instanceof RestElement - ); - if (this.body instanceof BlockStatement) { - this.body.addImplicitReturnExpressionToScope(); - } else { - this.scope.addReturnExpression(this.body); - } } - parseNode(esTreeNode: GenericEsTreeNode): void { - if (esTreeNode.body.type === NodeType.BlockStatement) { - this.body = new BlockStatement(esTreeNode.body, this, this.scope.hoistedBodyVarScope); + protected getObjectEntity(): ObjectEntity { + if (this.objectEntity !== null) { + return this.objectEntity; } - super.parseNode(esTreeNode); + return (this.objectEntity = new ObjectEntity([], OBJECT_PROTOTYPE)); } } - -ArrowFunctionExpression.prototype.preventChildBlockScope = true; diff --git a/src/ast/nodes/MemberExpression.ts b/src/ast/nodes/MemberExpression.ts index 4666bfdc9da..2d91fa649d5 100644 --- a/src/ast/nodes/MemberExpression.ts +++ b/src/ast/nodes/MemberExpression.ts @@ -14,7 +14,8 @@ import { type PathTracker, SHARED_RECURSION_TRACKER, UNKNOWN_PATH, - UnknownKey + UnknownKey, + UnknownNonAccessorKey } from '../utils/PathTracker'; import ExternalVariable from '../variables/ExternalVariable'; import type NamespaceVariable from '../variables/NamespaceVariable'; @@ -128,7 +129,11 @@ export default class MemberExpression extends NodeBase implements DeoptimizableE this.variable.deoptimizePath(path); } else if (!this.replacement) { if (path.length < MAX_PATH_DEPTH) { - this.object.deoptimizePath([this.getPropertyKey(), ...path]); + const propertyKey = this.getPropertyKey(); + this.object.deoptimizePath([ + propertyKey === UnknownKey ? UnknownNonAccessorKey : propertyKey, + ...path + ]); } } } diff --git a/src/ast/nodes/shared/FunctionBase.ts b/src/ast/nodes/shared/FunctionBase.ts new file mode 100644 index 00000000000..38b86e8c92a --- /dev/null +++ b/src/ast/nodes/shared/FunctionBase.ts @@ -0,0 +1,169 @@ +import type { NormalizedTreeshakingOptions } from '../../../rollup/types'; +import { type CallOptions, NO_ARGS } from '../../CallOptions'; +import { DeoptimizableEntity } from '../../DeoptimizableEntity'; +import { + BROKEN_FLOW_NONE, + type HasEffectsContext, + type InclusionContext +} from '../../ExecutionContext'; +import { NodeEvent } from '../../NodeEvents'; +import ReturnValueScope from '../../scopes/ReturnValueScope'; +import { type ObjectPath, PathTracker, UNKNOWN_PATH, UnknownKey } from '../../utils/PathTracker'; +import BlockStatement from '../BlockStatement'; +import * as NodeType from '../NodeType'; +import RestElement from '../RestElement'; +import type SpreadElement from '../SpreadElement'; +import { type ExpressionEntity, LiteralValueOrUnknown, UNKNOWN_EXPRESSION } from './Expression'; +import { + type ExpressionNode, + type GenericEsTreeNode, + type IncludeChildren, + NodeBase +} from './Node'; +import { ObjectEntity } from './ObjectEntity'; +import type { PatternNode } from './Pattern'; + +export default abstract class FunctionBase extends NodeBase { + declare async: boolean; + declare body: BlockStatement | ExpressionNode; + declare params: readonly PatternNode[]; + declare preventChildBlockScope: true; + declare scope: ReturnValueScope; + protected objectEntity: ObjectEntity | null = null; + private deoptimizedReturn = false; + + deoptimizePath(path: ObjectPath): void { + this.getObjectEntity().deoptimizePath(path); + if (path.length === 1 && path[0] === UnknownKey) { + // A reassignment of UNKNOWN_PATH is considered equivalent to having lost track + // which means the return expression needs to be reassigned + this.scope.getReturnExpression().deoptimizePath(UNKNOWN_PATH); + } + } + + deoptimizeThisOnEventAtPath( + event: NodeEvent, + path: ObjectPath, + thisParameter: ExpressionEntity, + recursionTracker: PathTracker + ): void { + if (path.length > 0) { + this.getObjectEntity().deoptimizeThisOnEventAtPath( + event, + path, + thisParameter, + recursionTracker + ); + } + } + + getLiteralValueAtPath( + path: ObjectPath, + recursionTracker: PathTracker, + origin: DeoptimizableEntity + ): LiteralValueOrUnknown { + return this.getObjectEntity().getLiteralValueAtPath(path, recursionTracker, origin); + } + + getReturnExpressionWhenCalledAtPath( + path: ObjectPath, + callOptions: CallOptions, + recursionTracker: PathTracker, + origin: DeoptimizableEntity + ): ExpressionEntity { + if (path.length > 0) { + return this.getObjectEntity().getReturnExpressionWhenCalledAtPath( + path, + callOptions, + recursionTracker, + origin + ); + } + if (this.async) { + if (!this.deoptimizedReturn) { + this.deoptimizedReturn = true; + this.scope.getReturnExpression().deoptimizePath(UNKNOWN_PATH); + this.context.requestTreeshakingPass(); + } + return UNKNOWN_EXPRESSION; + } + return this.scope.getReturnExpression(); + } + + hasEffectsWhenAccessedAtPath(path: ObjectPath, context: HasEffectsContext): boolean { + return this.getObjectEntity().hasEffectsWhenAccessedAtPath(path, context); + } + + hasEffectsWhenAssignedAtPath(path: ObjectPath, context: HasEffectsContext): boolean { + return this.getObjectEntity().hasEffectsWhenAssignedAtPath(path, context); + } + + hasEffectsWhenCalledAtPath( + path: ObjectPath, + callOptions: CallOptions, + context: HasEffectsContext + ): boolean { + if (path.length > 0) { + return this.getObjectEntity().hasEffectsWhenCalledAtPath(path, callOptions, context); + } + if (this.async) { + const { propertyReadSideEffects } = this.context.options + .treeshake as NormalizedTreeshakingOptions; + const returnExpression = this.scope.getReturnExpression(); + if ( + returnExpression.hasEffectsWhenCalledAtPath( + ['then'], + { args: NO_ARGS, thisParam: null, withNew: false }, + context + ) || + (propertyReadSideEffects && + (propertyReadSideEffects === 'always' || + returnExpression.hasEffectsWhenAccessedAtPath(['then'], context))) + ) { + return true; + } + } + for (const param of this.params) { + if (param.hasEffects(context)) return true; + } + return false; + } + + include(context: InclusionContext, includeChildrenRecursively: IncludeChildren): void { + this.included = true; + const { brokenFlow } = context; + context.brokenFlow = BROKEN_FLOW_NONE; + this.body.include(context, includeChildrenRecursively); + context.brokenFlow = brokenFlow; + } + + includeCallArguments( + context: InclusionContext, + args: readonly (ExpressionNode | SpreadElement)[] + ): void { + this.scope.includeCallArguments(context, args); + } + + initialise(): void { + this.scope.addParameterVariables( + this.params.map(param => param.declare('parameter', UNKNOWN_EXPRESSION)), + this.params[this.params.length - 1] instanceof RestElement + ); + if (this.body instanceof BlockStatement) { + this.body.addImplicitReturnExpressionToScope(); + } else { + this.scope.addReturnExpression(this.body); + } + } + + parseNode(esTreeNode: GenericEsTreeNode): void { + if (esTreeNode.body.type === NodeType.BlockStatement) { + this.body = new BlockStatement(esTreeNode.body, this, this.scope.hoistedBodyVarScope); + } + super.parseNode(esTreeNode); + } + + protected abstract getObjectEntity(): ObjectEntity; +} + +FunctionBase.prototype.preventChildBlockScope = true; diff --git a/src/ast/nodes/shared/FunctionNode.ts b/src/ast/nodes/shared/FunctionNode.ts index 1ad65e74b46..c11a007cbc2 100644 --- a/src/ast/nodes/shared/FunctionNode.ts +++ b/src/ast/nodes/shared/FunctionNode.ts @@ -1,128 +1,52 @@ -import type { NormalizedTreeshakingOptions } from '../../../rollup/types'; -import { type CallOptions, NO_ARGS } from '../../CallOptions'; -import { - BROKEN_FLOW_NONE, - type HasEffectsContext, - type InclusionContext -} from '../../ExecutionContext'; +import { type CallOptions } from '../../CallOptions'; +import { type HasEffectsContext, type InclusionContext } from '../../ExecutionContext'; import { EVENT_CALLED, type NodeEvent } from '../../NodeEvents'; import FunctionScope from '../../scopes/FunctionScope'; -import { type ObjectPath, UNKNOWN_PATH, UnknownKey } from '../../utils/PathTracker'; +import { type ObjectPath, PathTracker } from '../../utils/PathTracker'; import BlockStatement from '../BlockStatement'; import Identifier, { type IdentifierWithVariable } from '../Identifier'; -import RestElement from '../RestElement'; -import type SpreadElement from '../SpreadElement'; import { type ExpressionEntity, UNKNOWN_EXPRESSION } from './Expression'; -import { - type ExpressionNode, - type GenericEsTreeNode, - type IncludeChildren, - NodeBase -} from './Node'; +import FunctionBase from './FunctionBase'; +import { type IncludeChildren } from './Node'; import { ObjectEntity } from './ObjectEntity'; import { OBJECT_PROTOTYPE } from './ObjectPrototype'; import type { PatternNode } from './Pattern'; -export default class FunctionNode extends NodeBase { +export default class FunctionNode extends FunctionBase { declare async: boolean; declare body: BlockStatement; declare id: IdentifierWithVariable | null; declare params: readonly PatternNode[]; declare preventChildBlockScope: true; declare scope: FunctionScope; - private deoptimizedReturn = false; - private isPrototypeDeoptimized = false; + protected objectEntity: ObjectEntity | null = null; createScope(parentScope: FunctionScope): void { this.scope = new FunctionScope(parentScope, this.context); } - deoptimizePath(path: ObjectPath): void { - if (path.length === 1) { - if (path[0] === 'prototype') { - this.isPrototypeDeoptimized = true; - } else if (path[0] === UnknownKey) { - this.isPrototypeDeoptimized = true; - - // A reassignment of UNKNOWN_PATH is considered equivalent to having lost track - // which means the return expression needs to be reassigned as well - this.scope.getReturnExpression().deoptimizePath(UNKNOWN_PATH); - } - } - } - - // TODO for completeness, we should also track other events here deoptimizeThisOnEventAtPath( event: NodeEvent, path: ObjectPath, - thisParameter: ExpressionEntity + thisParameter: ExpressionEntity, + recursionTracker: PathTracker ): void { - if (event === EVENT_CALLED) { - if (path.length > 0) { - thisParameter.deoptimizePath(UNKNOWN_PATH); - } else { - this.scope.thisVariable.addEntityToBeDeoptimized(thisParameter); - } - } - } - - getReturnExpressionWhenCalledAtPath(path: ObjectPath): ExpressionEntity { - if (path.length !== 0) { - return UNKNOWN_EXPRESSION; - } - if (this.async) { - if (!this.deoptimizedReturn) { - this.deoptimizedReturn = true; - this.scope.getReturnExpression().deoptimizePath(UNKNOWN_PATH); - this.context.requestTreeshakingPass(); - } - return UNKNOWN_EXPRESSION; + super.deoptimizeThisOnEventAtPath(event, path, thisParameter, recursionTracker); + if (event === EVENT_CALLED && path.length === 0) { + this.scope.thisVariable.addEntityToBeDeoptimized(thisParameter); } - return this.scope.getReturnExpression(); } hasEffects(): boolean { return this.id !== null && this.id.hasEffects(); } - hasEffectsWhenAccessedAtPath(path: ObjectPath): boolean { - if (path.length <= 1) return false; - return path.length > 2 || path[0] !== 'prototype' || this.isPrototypeDeoptimized; - } - - hasEffectsWhenAssignedAtPath(path: ObjectPath): boolean { - if (path.length <= 1) { - return false; - } - return path.length > 2 || path[0] !== 'prototype' || this.isPrototypeDeoptimized; - } - hasEffectsWhenCalledAtPath( path: ObjectPath, callOptions: CallOptions, context: HasEffectsContext ): boolean { - if (path.length > 0) return true; - if (this.async) { - const { propertyReadSideEffects } = this.context.options - .treeshake as NormalizedTreeshakingOptions; - const returnExpression = this.scope.getReturnExpression(); - if ( - returnExpression.hasEffectsWhenCalledAtPath( - ['then'], - { args: NO_ARGS, thisParam: null, withNew: false }, - context - ) || - (propertyReadSideEffects && - (propertyReadSideEffects === 'always' || - returnExpression.hasEffectsWhenAccessedAtPath(['then'], context))) - ) { - return true; - } - } - for (const param of this.params) { - if (param.hasEffects(context)) return true; - } + if (super.hasEffectsWhenCalledAtPath(path, callOptions, context)) return true; const thisInit = context.replacedVariableInits.get(this.scope.thisVariable); context.replacedVariableInits.set( this.scope.thisVariable, @@ -149,7 +73,7 @@ export default class FunctionNode extends NodeBase { } include(context: InclusionContext, includeChildrenRecursively: IncludeChildren): void { - this.included = true; + super.include(context, includeChildrenRecursively); if (this.id) this.id.include(); const hasArguments = this.scope.argumentsVariable.included; for (const param of this.params) { @@ -157,34 +81,26 @@ export default class FunctionNode extends NodeBase { param.include(context, includeChildrenRecursively); } } - const { brokenFlow } = context; - context.brokenFlow = BROKEN_FLOW_NONE; - this.body.include(context, includeChildrenRecursively); - context.brokenFlow = brokenFlow; - } - - includeCallArguments( - context: InclusionContext, - args: readonly (ExpressionNode | SpreadElement)[] - ): void { - this.scope.includeCallArguments(context, args); } initialise(): void { - if (this.id !== null) { - this.id.declare('function', this); - } - this.scope.addParameterVariables( - this.params.map(param => param.declare('parameter', UNKNOWN_EXPRESSION)), - this.params[this.params.length - 1] instanceof RestElement - ); - this.body.addImplicitReturnExpressionToScope(); + super.initialise(); + this.id?.declare('function', this); } - parseNode(esTreeNode: GenericEsTreeNode): void { - this.body = new BlockStatement(esTreeNode.body, this, this.scope.hoistedBodyVarScope); - super.parseNode(esTreeNode); + protected getObjectEntity(): ObjectEntity { + if (this.objectEntity !== null) { + return this.objectEntity; + } + return (this.objectEntity = new ObjectEntity( + [ + { + key: 'prototype', + kind: 'init', + property: new ObjectEntity([], OBJECT_PROTOTYPE) + } + ], + OBJECT_PROTOTYPE + )); } } - -FunctionNode.prototype.preventChildBlockScope = true; diff --git a/src/ast/nodes/shared/ObjectEntity.ts b/src/ast/nodes/shared/ObjectEntity.ts index ad84a57eb36..1aff8dcd598 100644 --- a/src/ast/nodes/shared/ObjectEntity.ts +++ b/src/ast/nodes/shared/ObjectEntity.ts @@ -9,7 +9,8 @@ import { UNKNOWN_INTEGER_PATH, UNKNOWN_PATH, UnknownInteger, - UnknownKey + UnknownKey, + UnknownNonAccessorKey } from '../../utils/PathTracker'; import { ExpressionEntity, @@ -35,6 +36,7 @@ export class ObjectEntity extends ExpressionEntity { private readonly expressionsToBeDeoptimizedByKey: Record = Object.create(null); private readonly gettersByKey: PropertyMap = Object.create(null); + private hasLostTrack = false; private hasUnknownDeoptimizedInteger = false; private hasUnknownDeoptimizedProperty = false; private readonly propertiesAndGettersByKey: PropertyMap = Object.create(null); @@ -64,11 +66,16 @@ export class ObjectEntity extends ExpressionEntity { } } - deoptimizeAllProperties(): void { - if (this.hasUnknownDeoptimizedProperty) { + deoptimizeAllProperties(noAccessors?: boolean): void { + const isDeoptimized = this.hasLostTrack || this.hasUnknownDeoptimizedProperty; + if (noAccessors) { + this.hasUnknownDeoptimizedProperty = true; + } else { + this.hasLostTrack = true; + } + if (isDeoptimized) { return; } - this.hasUnknownDeoptimizedProperty = true; for (const properties of Object.values(this.propertiesAndGettersByKey).concat( Object.values(this.settersByKey) )) { @@ -82,7 +89,11 @@ export class ObjectEntity extends ExpressionEntity { } deoptimizeIntegerProperties(): void { - if (this.hasUnknownDeoptimizedProperty || this.hasUnknownDeoptimizedInteger) { + if ( + this.hasLostTrack || + this.hasUnknownDeoptimizedProperty || + this.hasUnknownDeoptimizedInteger + ) { return; } this.hasUnknownDeoptimizedInteger = true; @@ -96,15 +107,18 @@ export class ObjectEntity extends ExpressionEntity { this.deoptimizeCachedIntegerEntities(); } + // Assumption: If only a specific path is deoptimized, no accessors are created deoptimizePath(path: ObjectPath): void { - if (this.hasUnknownDeoptimizedProperty || this.immutable) return; + if (this.hasLostTrack || this.immutable) { + return; + } const key = path[0]; if (path.length === 1) { if (typeof key !== 'string') { if (key === UnknownInteger) { return this.deoptimizeIntegerProperties(); } - return this.deoptimizeAllProperties(); + return this.deoptimizeAllProperties(key === UnknownNonAccessorKey); } if (!this.deoptimizedPaths[key]) { this.deoptimizedPaths[key] = true; @@ -140,11 +154,11 @@ export class ObjectEntity extends ExpressionEntity { const [key, ...subPath] = path; if ( - this.hasUnknownDeoptimizedProperty || + this.hasLostTrack || // single paths that are deoptimized will not become getters or setters ((event === EVENT_CALLED || path.length > 1) && - typeof key === 'string' && - this.deoptimizedPaths[key]) + (this.hasUnknownDeoptimizedProperty || + (typeof key === 'string' && this.deoptimizedPaths[key]))) ) { thisParameter.deoptimizePath(UNKNOWN_PATH); return; @@ -273,7 +287,7 @@ export class ObjectEntity extends ExpressionEntity { return true; } - if (this.hasUnknownDeoptimizedProperty) return true; + if (this.hasLostTrack) return true; if (typeof key === 'string') { if (this.propertiesAndGettersByKey[key]) { const getters = this.gettersByKey[key]; @@ -318,8 +332,8 @@ export class ObjectEntity extends ExpressionEntity { return true; } - if (this.hasUnknownDeoptimizedProperty) return true; - // We do not need to test for unknown properties as in that case, hasUnknownDeoptimizedProperty is true + if (key === UnknownNonAccessorKey) return false; + if (this.hasLostTrack) return true; if (typeof key === 'string') { if (this.propertiesAndSettersByKey[key]) { const setters = this.settersByKey[key]; @@ -335,6 +349,12 @@ export class ObjectEntity extends ExpressionEntity { return true; } } + } else { + for (const setters of Object.values(this.settersByKey).concat([this.unmatchableSetters])) { + for (const setter of setters) { + if (setter.hasEffectsWhenAssignedAtPath(subPath, context)) return true; + } + } } if (this.prototypeExpression) { return this.prototypeExpression.hasEffectsWhenAssignedAtPath(path, context); @@ -434,6 +454,7 @@ export class ObjectEntity extends ExpressionEntity { private getMemberExpression(key: ObjectPathKey): ExpressionEntity | null { if ( + this.hasLostTrack || this.hasUnknownDeoptimizedProperty || typeof key !== 'string' || (this.hasUnknownDeoptimizedInteger && INTEGER_REG_EXP.test(key)) || diff --git a/src/ast/nodes/shared/knownGlobals.ts b/src/ast/nodes/shared/knownGlobals.ts index b628fd3924f..b42316d6bc3 100644 --- a/src/ast/nodes/shared/knownGlobals.ts +++ b/src/ast/nodes/shared/knownGlobals.ts @@ -1,11 +1,14 @@ /* eslint sort-keys: "off" */ +import { CallOptions } from '../../CallOptions'; +import { HasEffectsContext } from '../../ExecutionContext'; +import { UNKNOWN_NON_ACCESSOR_PATH } from '../../utils/PathTracker'; import type { ObjectPath } from '../../utils/PathTracker'; const ValueProperties = Symbol('Value Properties'); interface ValueDescription { - pure: boolean; + hasEffectsWhenCalled(callOptions: CallOptions, context: HasEffectsContext): boolean; } interface GlobalDescription { @@ -14,8 +17,17 @@ interface GlobalDescription { __proto__: null; } -const PURE: ValueDescription = { pure: true }; -const IMPURE: ValueDescription = { pure: false }; +const PURE: ValueDescription = { + hasEffectsWhenCalled() { + return false; + } +}; + +const IMPURE: ValueDescription = { + hasEffectsWhenCalled() { + return true; + } +}; // We use shortened variables to reduce file size here /* OBJECT */ @@ -30,6 +42,19 @@ const PF: GlobalDescription = { [ValueProperties]: PURE }; +/* FUNCTION THAT MUTATES FIRST ARG WITHOUT TRIGGERING ACCESSORS */ +const MUTATES_ARG_WITHOUT_ACCESSOR: GlobalDescription = { + __proto__: null, + [ValueProperties]: { + hasEffectsWhenCalled(callOptions, context) { + return ( + !callOptions.args.length || + callOptions.args[0].hasEffectsWhenAssignedAtPath(UNKNOWN_NON_ACCESSOR_PATH, context) + ); + } + } +}; + /* CONSTRUCTOR */ const C: GlobalDescription = { __proto__: null, @@ -173,6 +198,11 @@ const knownGlobals: GlobalDescription = { __proto__: null, [ValueProperties]: PURE, create: PF, + // Technically those can throw in certain situations, but we ignore this as + // code that relies on this will hopefully wrap this in a try-catch, which + // deoptimizes everything anyway + defineProperty: MUTATES_ARG_WITHOUT_ACCESSOR, + defineProperties: MUTATES_ARG_WITHOUT_ACCESSOR, getOwnPropertyDescriptor: PF, getOwnPropertyNames: PF, getOwnPropertySymbols: PF, @@ -847,7 +877,7 @@ for (const global of ['window', 'global', 'self', 'globalThis']) { knownGlobals[global] = knownGlobals; } -function getGlobalAtPath(path: ObjectPath): ValueDescription | null { +export function getGlobalAtPath(path: ObjectPath): ValueDescription | null { let currentGlobal: GlobalDescription | null = knownGlobals; for (const pathSegment of path) { if (typeof pathSegment !== 'string') { @@ -860,15 +890,3 @@ function getGlobalAtPath(path: ObjectPath): ValueDescription | null { } return currentGlobal[ValueProperties]; } - -export function isPureGlobal(path: ObjectPath): boolean { - const globalAtPath = getGlobalAtPath(path); - return globalAtPath !== null && globalAtPath.pure; -} - -export function isGlobalMember(path: ObjectPath): boolean { - if (path.length === 1) { - return path[0] === 'undefined' || getGlobalAtPath(path) !== null; - } - return getGlobalAtPath(path.slice(0, -1)) !== null; -} diff --git a/src/ast/utils/PathTracker.ts b/src/ast/utils/PathTracker.ts index 1ffa0554c82..5b0679a6187 100644 --- a/src/ast/utils/PathTracker.ts +++ b/src/ast/utils/PathTracker.ts @@ -2,12 +2,23 @@ import { getOrCreate } from '../../utils/getOrCreate'; import type { Entity } from '../Entity'; export const UnknownKey = Symbol('Unknown Key'); +export const UnknownNonAccessorKey = Symbol('Unknown Non-Accessor Key'); export const UnknownInteger = Symbol('Unknown Integer'); -export type ObjectPathKey = string | typeof UnknownKey | typeof UnknownInteger; +export type ObjectPathKey = + | string + | typeof UnknownKey + | typeof UnknownNonAccessorKey + | typeof UnknownInteger; export type ObjectPath = ObjectPathKey[]; export const EMPTY_PATH: ObjectPath = []; export const UNKNOWN_PATH: ObjectPath = [UnknownKey]; +// For deoptimizations, this means we are modifying an unknown property but did +// not lose track of the object or are creating a setter/getter; +// For assignment effects it means we do not check for setter/getter effects +// but only if something is mutated that is included, which is relevant for +// Object.defineProperty +export const UNKNOWN_NON_ACCESSOR_PATH: ObjectPath = [UnknownNonAccessorKey]; export const UNKNOWN_INTEGER_PATH: ObjectPath = [UnknownInteger]; const EntitiesKey = Symbol('Entities'); @@ -16,6 +27,7 @@ interface EntityPaths { [EntitiesKey]: Set; [UnknownInteger]?: EntityPaths; [UnknownKey]?: EntityPaths; + [UnknownNonAccessorKey]?: EntityPaths; } export class PathTracker { @@ -62,6 +74,7 @@ interface DiscriminatedEntityPaths { [EntitiesKey]: Map>; [UnknownInteger]?: DiscriminatedEntityPaths; [UnknownKey]?: DiscriminatedEntityPaths; + [UnknownNonAccessorKey]?: DiscriminatedEntityPaths; } export class DiscriminatedPathTracker { diff --git a/src/ast/variables/GlobalVariable.ts b/src/ast/variables/GlobalVariable.ts index 7ac5c466962..7d6928c5f9c 100644 --- a/src/ast/variables/GlobalVariable.ts +++ b/src/ast/variables/GlobalVariable.ts @@ -1,15 +1,28 @@ -import { isGlobalMember, isPureGlobal } from '../nodes/shared/knownGlobals'; +import { CallOptions } from '../CallOptions'; +import { HasEffectsContext } from '../ExecutionContext'; +import { getGlobalAtPath } from '../nodes/shared/knownGlobals'; import type { ObjectPath } from '../utils/PathTracker'; import Variable from './Variable'; export default class GlobalVariable extends Variable { + // Ensure we use live-bindings for globals as we do not know if they have + // been reassigned isReassigned = true; hasEffectsWhenAccessedAtPath(path: ObjectPath): boolean { - return !isGlobalMember([this.name, ...path]); + if (path.length === 0) { + // Technically, "undefined" is a global variable of sorts + return this.name !== 'undefined' && getGlobalAtPath([this.name]) === null; + } + return getGlobalAtPath([this.name, ...path].slice(0, -1)) === null; } - hasEffectsWhenCalledAtPath(path: ObjectPath): boolean { - return !isPureGlobal([this.name, ...path]); + hasEffectsWhenCalledAtPath( + path: ObjectPath, + callOptions: CallOptions, + context: HasEffectsContext + ): boolean { + const globalAtPath = getGlobalAtPath([this.name, ...path]); + return globalAtPath === null || globalAtPath.hasEffectsWhenCalled(callOptions, context); } } diff --git a/test/form/samples/for-loop-assignment/_config.js b/test/form/samples/for-loop-assignment/_config.js new file mode 100644 index 00000000000..120cbe0c1b7 --- /dev/null +++ b/test/form/samples/for-loop-assignment/_config.js @@ -0,0 +1,4 @@ +module.exports = { + description: 'removes assignments with computed indexes in for loops', + expectedWarnings: ['EMPTY_BUNDLE'] +}; diff --git a/test/form/samples/for-loop-assignment/_expected.js b/test/form/samples/for-loop-assignment/_expected.js new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/test/form/samples/for-loop-assignment/_expected.js @@ -0,0 +1 @@ + diff --git a/test/form/samples/for-loop-assignment/main.js b/test/form/samples/for-loop-assignment/main.js new file mode 100644 index 00000000000..948cb07f092 --- /dev/null +++ b/test/form/samples/for-loop-assignment/main.js @@ -0,0 +1,5 @@ +const lut = []; + +for (let i = 0; i < 256; i++) { + lut[i] = i < 16 ? '0' : ''; +} diff --git a/test/form/samples/function-iterable-prototype/_config.js b/test/form/samples/function-iterable-prototype/_config.js new file mode 100644 index 00000000000..a4bea7d5c41 --- /dev/null +++ b/test/form/samples/function-iterable-prototype/_config.js @@ -0,0 +1,4 @@ +module.exports = { + description: 'Removes unused functions where the prototype is iterable', + expectedWarnings: ['EMPTY_BUNDLE'] +}; diff --git a/test/form/samples/function-iterable-prototype/_expected.js b/test/form/samples/function-iterable-prototype/_expected.js new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/test/form/samples/function-iterable-prototype/_expected.js @@ -0,0 +1 @@ + diff --git a/test/form/samples/function-iterable-prototype/main.js b/test/form/samples/function-iterable-prototype/main.js new file mode 100644 index 00000000000..fd07b376992 --- /dev/null +++ b/test/form/samples/function-iterable-prototype/main.js @@ -0,0 +1,5 @@ +function AsyncGenerator(gen) {} + +AsyncGenerator.prototype[Symbol.asyncIterator] = function () { + return this; +}; diff --git a/test/form/samples/known-globals/_expected.js b/test/form/samples/known-globals/_expected.js index c0b933d7b56..94e37d9d5ec 100644 --- a/test/form/samples/known-globals/_expected.js +++ b/test/form/samples/known-globals/_expected.js @@ -1 +1,2 @@ -console.log('main'); +// retained +Math[globalThis.unknown].foo; diff --git a/test/form/samples/known-globals/main.js b/test/form/samples/known-globals/main.js index 01b36b51d3a..f0a93f55834 100644 --- a/test/form/samples/known-globals/main.js +++ b/test/form/samples/known-globals/main.js @@ -1,4 +1,7 @@ -console.log('main'); +// retained +Math[globalThis.unknown].foo; + +// removed const a = setTimeout; const b = globalThis.setTimeout; const c = Math.max(1, 2); diff --git a/test/form/samples/object-define-property/_config.js b/test/form/samples/object-define-property/_config.js new file mode 100644 index 00000000000..406267436f3 --- /dev/null +++ b/test/form/samples/object-define-property/_config.js @@ -0,0 +1,3 @@ +module.exports = { + description: 'allows globals to have parameter mutation side effects' +}; diff --git a/test/form/samples/object-define-property/_expected.js b/test/form/samples/object-define-property/_expected.js new file mode 100644 index 00000000000..58141e185ae --- /dev/null +++ b/test/form/samples/object-define-property/_expected.js @@ -0,0 +1,7 @@ +const retained1 = {}; +Object.defineProperty(retained1, 'foo', { value: true }); +console.log(retained1); + +const retained2 = {}; +Object.defineProperties(retained2, { bar: { value: true } }); +console.log(retained2); diff --git a/test/form/samples/object-define-property/main.js b/test/form/samples/object-define-property/main.js new file mode 100644 index 00000000000..ee9e5d9df05 --- /dev/null +++ b/test/form/samples/object-define-property/main.js @@ -0,0 +1,23 @@ +const removed = {}; +Object.defineProperty(removed, 'foo', { value: true }); +Object.defineProperties(removed, { bar: { value: true } }); + +const retained1 = {}; +Object.defineProperty(retained1, 'foo', { value: true }); +console.log(retained1); + +const retained2 = {}; +Object.defineProperties(retained2, { bar: { value: true } }); +console.log(retained2); + +const removed2 = []; +Object.defineProperty(removed2, 'foo', { value: true }); + +class removed3 {} +Object.defineProperty(removed3, 'foo', { value: true }); + +function removed4() {} +Object.defineProperty(removed4, 'foo', { value: true }); + +const removed5 = () => {}; +Object.defineProperty(removed5, 'foo', { value: true }); diff --git a/test/form/samples/object-expression/unknown-setter-no-side-effect2/_config.js b/test/form/samples/object-expression/unknown-setter-no-side-effect2/_config.js new file mode 100644 index 00000000000..1d59c54d22e --- /dev/null +++ b/test/form/samples/object-expression/unknown-setter-no-side-effect2/_config.js @@ -0,0 +1,4 @@ +module.exports = { + description: 'removes unknown setter access without side effect', + options: { external: ['external'] } +}; diff --git a/test/form/samples/object-expression/unknown-setter-no-side-effect2/_expected.js b/test/form/samples/object-expression/unknown-setter-no-side-effect2/_expected.js new file mode 100644 index 00000000000..bb9900f6dcc --- /dev/null +++ b/test/form/samples/object-expression/unknown-setter-no-side-effect2/_expected.js @@ -0,0 +1,9 @@ +import { unknown } from 'external'; + +const obj2 = { + set [unknown](value) { + console.log('effect'); + } +}; + +obj2[unknown] = true; diff --git a/test/form/samples/object-expression/unknown-setter-no-side-effect2/main.js b/test/form/samples/object-expression/unknown-setter-no-side-effect2/main.js new file mode 100644 index 00000000000..5a8c4863ad1 --- /dev/null +++ b/test/form/samples/object-expression/unknown-setter-no-side-effect2/main.js @@ -0,0 +1,15 @@ +import { unknown } from 'external'; + +const obj = { + set [unknown](value) {} +}; + +obj[unknown] = true; + +const obj2 = { + set [unknown](value) { + console.log('effect'); + } +}; + +obj2[unknown] = true; diff --git a/test/function/samples/function-getter-side-effects/_config.js b/test/function/samples/function-getter-side-effects/_config.js new file mode 100644 index 00000000000..904e3d0e679 --- /dev/null +++ b/test/function/samples/function-getter-side-effects/_config.js @@ -0,0 +1,3 @@ +module.exports = { + description: 'respects getters created on functions' +}; diff --git a/test/function/samples/function-getter-side-effects/main.js b/test/function/samples/function-getter-side-effects/main.js new file mode 100644 index 00000000000..4fca02d3838 --- /dev/null +++ b/test/function/samples/function-getter-side-effects/main.js @@ -0,0 +1,17 @@ +let funDeclEffect = false; +function funDecl() {} +Object.defineProperty(funDecl, 'foo', { get() { funDeclEffect = true; }}); +funDecl.foo; +assert.ok(funDeclEffect, 'function declaration'); + +let funExpEffect = false; +const funExp = function () {}; +Object.defineProperty(funExp, 'foo', { get() { funExpEffect = true }}); +funExp.foo; +assert.ok(funExpEffect, 'function expression'); + +let arrowEffect = false; +const arrow = function () {}; +Object.defineProperty(arrow, 'foo', { get() { arrowEffect = true }}); +arrow.foo; +assert.ok(arrowEffect, 'arrow function');