diff --git a/cli/help.md b/cli/help.md index 8d1c75e35b3..f47842fb87b 100644 --- a/cli/help.md +++ b/cli/help.md @@ -19,11 +19,11 @@ Basic options: -p, --plugin Use the plugin specified (may be repeated) -v, --version Show version number -w, --watch Watch files in bundle and rebuild on changes ---amd.id ID for AMD module (default is anonymous) --amd.autoId Generate the AMD ID based off the chunk name --amd.basePath Path to prepend to auto generated AMD ID --amd.define Function to use in place of `define` --amd.forceJsExtensionForImports Use `.js` extension in AMD imports +--amd.id ID for AMD module (default is anonymous) --assetFileNames Name pattern for emitted assets --banner Code to insert at top of bundle (outside wrapper) --chunkFileNames Name pattern for emitted secondary chunks @@ -35,16 +35,21 @@ Basic options: --no-esModule Do not add __esModule property --exports Specify export mode (auto, default, named, none) --extend Extend global variable defined by --name ---no-externalLiveBindings Do not generate code to support live bindings --no-externalImportAssertions Omit import assertions in "es" output +--no-externalLiveBindings Do not generate code to support live bindings --failAfterWarnings Exit with an error if the build produced warnings --footer Code to insert at end of bundle (outside wrapper) --no-freeze Do not freeze namespace objects --generatedCode Which code features to use (es5/es2015) +--generatedCode.arrowFunctions Use arrow functions in generated code +--generatedCode.constBindings Use "const" in generated code +--generatedCode.objectShorthand Use shorthand properties in generated code +--no-generatedCode.reservedNamesAsProps Always quote reserved names as props +--generatedCode.symbols Use symbols in generated code --no-hoistTransitiveImports Do not hoist transitive imports into entry chunks --no-indent Don't indent result ---no-interop Do not include interop block --inlineDynamicImports Create single bundle when using dynamic imports +--no-interop Do not include interop block --intro Code to insert at top of bundle (inside wrapper) --no-makeAbsoluteExternalsRelative Prevent normalization of external imports --maxParallelFileOps How many files to read in parallel @@ -69,22 +74,24 @@ Basic options: --no-systemNullSetters Do not replace empty SystemJS setters with `null` --no-treeshake Disable tree-shaking optimisations --no-treeshake.annotations Ignore pure call annotations +--treeshake.correctVarValueBeforeDeclaration Deoptimize variables until declared +--treeshake.manualPureFunctions Manually declare functions as pure --no-treeshake.moduleSideEffects Assume modules have no side effects --no-treeshake.propertyReadSideEffects Ignore property access side effects --no-treeshake.tryCatchDeoptimization Do not turn off try-catch-tree-shaking --no-treeshake.unknownGlobalSideEffects Assume unknown globals do not throw +--validate Validate output --waitForBundleInput Wait for bundle input files --watch.buildDelay Throttle watch rebuilds --no-watch.clearScreen Do not clear the screen when rebuilding ---watch.skipWrite Do not write files to disk when watching --watch.exclude Exclude files from being watched --watch.include Limit watching to specified files ---watch.onStart Shell command to run on `"START"` event ---watch.onBundleStart Shell command to run on `"BUNDLE_START"` event --watch.onBundleEnd Shell command to run on `"BUNDLE_END"` event +--watch.onBundleStart Shell command to run on `"BUNDLE_START"` event --watch.onEnd Shell command to run on `"END"` event --watch.onError Shell command to run on `"ERROR"` event ---validate Validate output +--watch.onStart Shell command to run on `"START"` event +--watch.skipWrite Do not write files to disk when watching Examples: diff --git a/docs/01-command-line-reference.md b/docs/01-command-line-reference.md index 4af0954a175..966e651372f 100755 --- a/docs/01-command-line-reference.md +++ b/docs/01-command-line-reference.md @@ -350,11 +350,11 @@ Many options have command line equivalents. In those cases, any arguments passed -p, --plugin Use the plugin specified (may be repeated) -v, --version Show version number -w, --watch Watch files in bundle and rebuild on changes ---amd.id ID for AMD module (default is anonymous) --amd.autoId Generate the AMD ID based off the chunk name --amd.basePath Path to prepend to auto generated AMD ID --amd.define Function to use in place of `define` --amd.forceJsExtensionForImports Use `.js` extension in AMD imports +--amd.id ID for AMD module (default is anonymous) --assetFileNames Name pattern for emitted assets --banner Code to insert at top of bundle (outside wrapper) --chunkFileNames Name pattern for emitted secondary chunks @@ -372,10 +372,15 @@ Many options have command line equivalents. In those cases, any arguments passed --footer Code to insert at end of bundle (outside wrapper) --no-freeze Do not freeze namespace objects --generatedCode Which code features to use (es5/es2015) +--generatedCode.arrowFunctions Use arrow functions in generated code +--generatedCode.constBindings Use "const" in generated code +--generatedCode.objectShorthand Use shorthand properties in generated code +--no-generatedCode.reservedNamesAsProps Always quote reserved names as props +--generatedCode.symbols Use symbols in generated code --no-hoistTransitiveImports Do not hoist transitive imports into entry chunks --no-indent Don't indent result ---interop Handle default/namespace imports from AMD/CommonJS --inlineDynamicImports Create single bundle when using dynamic imports +--no-interop Do not include interop block --intro Code to insert at top of bundle (inside wrapper) --no-makeAbsoluteExternalsRelative Prevent normalization of external imports --maxParallelFileOps How many files to read in parallel @@ -400,22 +405,24 @@ Many options have command line equivalents. In those cases, any arguments passed --no-systemNullSetters Do not replace empty SystemJS setters with `null` --no-treeshake Disable tree-shaking optimisations --no-treeshake.annotations Ignore pure call annotations +--treeshake.correctVarValueBeforeDeclaration Deoptimize variables until declared +--treeshake.manualPureFunctions Manually declare functions as pure --no-treeshake.moduleSideEffects Assume modules have no side effects --no-treeshake.propertyReadSideEffects Ignore property access side effects --no-treeshake.tryCatchDeoptimization Do not turn off try-catch-tree-shaking --no-treeshake.unknownGlobalSideEffects Assume unknown globals do not throw +--validate Validate output --waitForBundleInput Wait for bundle input files --watch.buildDelay Throttle watch rebuilds --no-watch.clearScreen Do not clear the screen when rebuilding ---watch.skipWrite Do not write files to disk when watching --watch.exclude Exclude files from being watched --watch.include Limit watching to specified files ---watch.onStart Shell command to run on `"START"` event ---watch.onBundleStart Shell command to run on `"BUNDLE_START"` event --watch.onBundleEnd Shell command to run on `"BUNDLE_END"` event +--watch.onBundleStart Shell command to run on `"BUNDLE_START"` event --watch.onEnd Shell command to run on `"END"` event --watch.onError Shell command to run on `"ERROR"` event ---validate Validate output +--watch.onStart Shell command to run on `"START"` event +--watch.skipWrite Do not write files to disk when watching ``` The flags listed below are only available via the command line interface. All other flags correspond to and override their config file equivalents, see the [big list of options](guide/en/#big-list-of-options) for details. diff --git a/docs/999-big-list-of-options.md b/docs/999-big-list-of-options.md index 2f87c1887b0..b87062f362b 100755 --- a/docs/999-big-list-of-options.md +++ b/docs/999-big-list-of-options.md @@ -1706,6 +1706,37 @@ logBeforeDeclaration = true; logIfEnabled(); // needs to be retained as it displays a log ``` +**treeshake.manualPureFunctions**
Type: `string[]`
CLI: `--treeshake.manualPureFunctions ` + +Allows to manually define a list of function names that should always be considered "pure", i.e. they have no side effects like changing global state etc. when called. The check is performed solely by name. + +This can not only help with dead code removal, but can also improve JavaScript chunk generation especially when using [`experimentalMinChunkSize`](guide/en/#experimentalminchunksize). + +Besides any functions matching that name, any properties on a pure function and any functions returned from a pure functions will also be considered pure functions, and accessing any properties is not checked for side effects. + +```js +// rollup.config.js +export default { + treeshake: { + preset: 'smallest', + manualPureFunctions: ['styled', 'local'] + } + // ... +}; + +// code +import styled from 'styled-components'; +const local = console.log; + +local(); // removed +styled.div` + color: blue; +`; // removed +styled?.div(); // removed +styled()(); // removed +styled().div(); // removed +``` + **treeshake.moduleSideEffects**
Type: `boolean | "no-external" | string[] | (id: string, external: boolean) => boolean`
CLI: `--treeshake.moduleSideEffects`/`--no-treeshake.moduleSideEffects`/`--treeshake.moduleSideEffects no-external`
Default: `true` If `false`, assume modules and external dependencies from which nothing is imported do not have other side effects like mutating global variables or logging without checking. For external dependencies, this will suppress empty imports: diff --git a/package.json b/package.json index 2d86a8c03ec..374245e0771 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,12 @@ "test": "npm run build && npm run test:all", "test:cjs": "npm run build:cjs && npm run test:only", "test:quick": "mocha -b test/test.js", - "test:all": "concurrently --kill-others-on-fail -c green,blue,magenta,cyan,red 'npm:test:only' 'npm:test:browser' 'npm:test:typescript' 'npm:test:leak' 'npm:test:package'", + "test:all": "concurrently --kill-others-on-fail -c green,blue,magenta,cyan,red 'npm:test:only' 'npm:test:browser' 'npm:test:typescript' 'npm:test:leak' 'npm:test:package' 'npm:test:options'", "test:coverage": "npm run build:cjs && shx rm -rf coverage/* && nyc --reporter html mocha test/test.js", "test:coverage:browser": "npm run build && shx rm -rf coverage/* && nyc mocha test/browser/index.js", "test:leak": "node --expose-gc test/leak/index.js", "test:package": "node scripts/test-package.js", + "test:options": "node scripts/test-options.js", "test:only": "mocha test/test.js", "test:typescript": "shx rm -rf test/typescript/dist && shx cp -r dist test/typescript/ && tsc --noEmit -p test/typescript && tsc --noEmit", "test:browser": "mocha test/browser/index.js", diff --git a/scripts/test-options.js b/scripts/test-options.js new file mode 100644 index 00000000000..4ac2e9ed4a3 --- /dev/null +++ b/scripts/test-options.js @@ -0,0 +1,93 @@ +import { readFile } from 'node:fs/promises'; + +const [optionsText, helpText, commandReferenceText] = await Promise.all([ + readFile(new URL('../docs/999-big-list-of-options.md', import.meta.url), 'utf8'), + readFile(new URL('../cli/help.md', import.meta.url), 'utf8'), + readFile(new URL('../docs/01-command-line-reference.md', import.meta.url), 'utf8') +]); + +const optionSections = optionsText.split('\n### '); +let searchedOptions = ''; +for (const section of optionSections.slice(1)) { + if (!(section.startsWith('Experimental') || section.startsWith('Deprecated'))) { + searchedOptions += section; + } +} + +const findCliOptionRegExp = /CLI: (`(-(\w)[^`]*)`\/)?`(--([\w.]+)[^`]*)`/g; + +const allCliOptions = []; +let match; + +while ((match = findCliOptionRegExp.exec(searchedOptions)) !== null) { + allCliOptions.push({ long: match[5], short: match[3] }); +} + +const findOptionInHelpRegExp = /^(-(\w), )?--(no-)?([\w.]+)/gm; + +const cliOptionsInHelp = new Map(); + +while ((match = findOptionInHelpRegExp.exec(helpText)) !== null) { + cliOptionsInHelp.set(match[4], match[2]); +} + +let failed = false; + +for (const { long, short } of allCliOptions) { + if (!cliOptionsInHelp.has(long)) { + failed = true; + console.error(`Could find neither --${long} nor --no-${long} in help.md.`); + } + const optionInHelp = cliOptionsInHelp.get(long); + if (short !== optionInHelp) { + failed = true; + console.error( + `Inconsistent option with shortcut. Expected -${short}/--${long}, got -${optionInHelp.short}/--${optionInHelp.long}.` + ); + } +} + +if (failed) { + process.exit(1); +} + +let current = null; +for (const [long, short] of cliOptionsInHelp) { + if (!short) { + if (current && long < current) { + console.error( + `Options in help.md are not sorted properly. "${long}" should occur before "${current}".` + ); + process.exit(1); + } + current = long; + } +} + +const splitHelpText = helpText.split('\n'); +for (const line of splitHelpText) { + if (line.length > 80) { + console.error(`The following line in help.md exceeds the limit of 80 characters:\n${line}`); + process.exit(1); + } +} + +const helpOptionLines = splitHelpText.filter(line => line.startsWith('-')); + +const cliFlagsText = commandReferenceText + .split('\n### ') + .find(text => text.startsWith('Command line flags')); +const optionListLines = cliFlagsText + .match(/```text\n([\S\s]*?)\n```/)[1] + .split('\n') + .filter(line => line.startsWith('-')); + +for (const [index, line] of helpOptionLines.entries()) { + const optionListLine = optionListLines[index]; + if (line !== optionListLine) { + console.error( + `The command lines in 01-command-line-reference.md does not match help.md. Expected line:\n${line}\n\nReceived line:\n${optionListLine}` + ); + process.exit(1); + } +} diff --git a/src/Graph.ts b/src/Graph.ts index db02abed7cd..099d3d3e9de 100644 --- a/src/Graph.ts +++ b/src/Graph.ts @@ -24,6 +24,8 @@ import { } from './utils/error'; import { analyseModuleExecution } from './utils/executionOrder'; import { addAnnotations } from './utils/pureComments'; +import { getPureFunctions } from './utils/pureFunctions'; +import type { PureFunctions } from './utils/pureFunctions'; import { timeEnd, timeStart } from './utils/timers'; import { markModuleAndImpureDependenciesAsExecuted } from './utils/traverseStaticDependencies'; @@ -59,6 +61,7 @@ export default class Graph { needsTreeshakingPass = false; phase: BuildPhase = BuildPhase.LOAD_AND_PARSE; readonly pluginDriver: PluginDriver; + readonly pureFunctions: PureFunctions; readonly scope = new GlobalScope(); readonly watchFiles: Record = Object.create(null); watchMode = false; @@ -94,6 +97,7 @@ export default class Graph { this.acornParser = acorn.Parser.extend(...(options.acornInjectPlugins as any[])); this.moduleLoader = new ModuleLoader(this, this.modulesById, this.options, this.pluginDriver); this.fileOperationQueue = new Queue(options.maxParallelFileOps); + this.pureFunctions = getPureFunctions(options); } async build(): Promise { diff --git a/src/Module.ts b/src/Module.ts index e2f23dd83b4..0bda040dcc7 100644 --- a/src/Module.ts +++ b/src/Module.ts @@ -71,6 +71,7 @@ import { getAssertionsFromImportExportDeclaration } from './utils/parseAssertions'; import { basename, extname } from './utils/path'; +import type { PureFunctions } from './utils/pureFunctions'; import type { RenderOptions } from './utils/renderHelpers'; import { timeEnd, timeStart } from './utils/timers'; import { markModuleAndImpureDependenciesAsExecuted } from './utils/traverseStaticDependencies'; @@ -116,6 +117,7 @@ export interface AstContext { includeDynamicImport: (node: ImportExpression) => void; includeVariableInModule: (variable: Variable) => void; magicString: MagicString; + manualPureFunctions: PureFunctions; module: Module; // not to be used for tree-shaking moduleContext: string; options: NormalizedInputOptions; @@ -784,6 +786,7 @@ export default class Module { includeDynamicImport: this.includeDynamicImport.bind(this), includeVariableInModule: this.includeVariableInModule.bind(this), magicString: this.magicString, + manualPureFunctions: this.graph.pureFunctions, module: this, moduleContext: this.context, options: this.options, diff --git a/src/ast/nodes/ArrayExpression.ts b/src/ast/nodes/ArrayExpression.ts index 3ae8d39199f..cddbdd70117 100644 --- a/src/ast/nodes/ArrayExpression.ts +++ b/src/ast/nodes/ArrayExpression.ts @@ -49,7 +49,7 @@ export default class ArrayExpression extends NodeBase { interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { return this.getObjectEntity().getReturnExpressionWhenCalledAtPath( path, interaction, diff --git a/src/ast/nodes/CallExpression.ts b/src/ast/nodes/CallExpression.ts index 0d915d6ed22..7f7226a9e5e 100644 --- a/src/ast/nodes/CallExpression.ts +++ b/src/ast/nodes/CallExpression.ts @@ -20,7 +20,7 @@ import type * as NodeType from './NodeType'; import type SpreadElement from './SpreadElement'; import type Super from './Super'; import CallExpressionBase from './shared/CallExpressionBase'; -import { type ExpressionEntity, UNKNOWN_EXPRESSION } from './shared/Expression'; +import { type ExpressionEntity, UNKNOWN_RETURN_EXPRESSION } from './shared/Expression'; import { type ExpressionNode, INCLUDE_PARAMETERS, type IncludeChildren } from './shared/Node'; export default class CallExpression extends CallExpressionBase implements DeoptimizableEntity { @@ -120,9 +120,9 @@ export default class CallExpression extends CallExpressionBase implements Deopti protected getReturnExpression( recursionTracker: PathTracker = SHARED_RECURSION_TRACKER - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { if (this.returnExpression === null) { - this.returnExpression = UNKNOWN_EXPRESSION; + this.returnExpression = UNKNOWN_RETURN_EXPRESSION; return (this.returnExpression = this.callee.getReturnExpressionWhenCalledAtPath( EMPTY_PATH, this.interaction, diff --git a/src/ast/nodes/ConditionalExpression.ts b/src/ast/nodes/ConditionalExpression.ts index 25d838ede85..f605dd11bd6 100644 --- a/src/ast/nodes/ConditionalExpression.ts +++ b/src/ast/nodes/ConditionalExpression.ts @@ -80,23 +80,26 @@ export default class ConditionalExpression extends NodeBase implements Deoptimiz interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { const usedBranch = this.getUsedBranch(); if (!usedBranch) - return new MultiExpression([ - this.consequent.getReturnExpressionWhenCalledAtPath( - path, - interaction, - recursionTracker, - origin - ), - this.alternate.getReturnExpressionWhenCalledAtPath( - path, - interaction, - recursionTracker, - origin - ) - ]); + return [ + new MultiExpression([ + this.consequent.getReturnExpressionWhenCalledAtPath( + path, + interaction, + recursionTracker, + origin + )[0], + this.alternate.getReturnExpressionWhenCalledAtPath( + path, + interaction, + recursionTracker, + origin + )[0] + ]), + false + ]; this.expressionsToBeDeoptimized.push(origin); return usedBranch.getReturnExpressionWhenCalledAtPath( path, diff --git a/src/ast/nodes/Identifier.ts b/src/ast/nodes/Identifier.ts index 843347b4b2d..2fd56ba5c97 100644 --- a/src/ast/nodes/Identifier.ts +++ b/src/ast/nodes/Identifier.ts @@ -3,6 +3,7 @@ import type MagicString from 'magic-string'; import type { NormalizedTreeshakingOptions } from '../../rollup/types'; import { BLANK } from '../../utils/blank'; import { errorIllegalImportReassignment } from '../../utils/error'; +import { PureFunctionKey } from '../../utils/pureFunctions'; import type { NodeRenderOptions, RenderOptions } from '../../utils/renderHelpers'; import type { DeoptimizableEntity } from '../DeoptimizableEntity'; import type { HasEffectsContext, InclusionContext } from '../ExecutionContext'; @@ -131,13 +132,15 @@ export default class Identifier extends NodeBase implements PatternNode { interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { - return this.getVariableRespectingTDZ()!.getReturnExpressionWhenCalledAtPath( - path, - interaction, - recursionTracker, - origin - ); + ): [expression: ExpressionEntity, isPure: boolean] { + const [expression, isPure] = + this.getVariableRespectingTDZ()!.getReturnExpressionWhenCalledAtPath( + path, + interaction, + recursionTracker, + origin + ); + return [expression, isPure || this.isPureFunction(path)]; } hasEffects(context: HasEffectsContext): boolean { @@ -148,6 +151,7 @@ export default class Identifier extends NodeBase implements PatternNode { return ( (this.context.options.treeshake as NormalizedTreeshakingOptions).unknownGlobalSideEffects && this.variable instanceof GlobalVariable && + !this.isPureFunction(EMPTY_PATH) && this.variable.hasEffectsOnInteractionAtPath( EMPTY_PATH, NODE_INTERACTION_UNKNOWN_ACCESS, @@ -165,6 +169,7 @@ export default class Identifier extends NodeBase implements PatternNode { case INTERACTION_ACCESSED: { return ( this.variable !== null && + !this.isPureFunction(path) && this.getVariableRespectingTDZ()!.hasEffectsOnInteractionAtPath(path, interaction, context) ); } @@ -174,10 +179,9 @@ export default class Identifier extends NodeBase implements PatternNode { )!.hasEffectsOnInteractionAtPath(path, interaction, context); } case INTERACTION_CALLED: { - return this.getVariableRespectingTDZ()!.hasEffectsOnInteractionAtPath( - path, - interaction, - context + return ( + !this.isPureFunction(path) && + this.getVariableRespectingTDZ()!.hasEffectsOnInteractionAtPath(path, interaction, context) ); } } @@ -287,6 +291,21 @@ export default class Identifier extends NodeBase implements PatternNode { } return this.variable; } + + private isPureFunction(path: ObjectPath) { + let currentPureFunction = this.context.manualPureFunctions[this.name]; + for (const segment of path) { + if (currentPureFunction) { + if (currentPureFunction[PureFunctionKey]) { + return true; + } + currentPureFunction = currentPureFunction[segment as string]; + } else { + return false; + } + } + return currentPureFunction?.[PureFunctionKey] as boolean; + } } function closestParentFunctionOrProgram(node: any): any { diff --git a/src/ast/nodes/Literal.ts b/src/ast/nodes/Literal.ts index 247fbf276f8..5259ada5ef2 100644 --- a/src/ast/nodes/Literal.ts +++ b/src/ast/nodes/Literal.ts @@ -17,7 +17,7 @@ import type * as NodeType from './NodeType'; import { type ExpressionEntity, type LiteralValueOrUnknown, - UNKNOWN_EXPRESSION, + UNKNOWN_RETURN_EXPRESSION, UnknownValue } from './shared/Expression'; import { type GenericEsTreeNode, NodeBase } from './shared/Node'; @@ -50,8 +50,10 @@ export default class Literal extends Node return this.value; } - getReturnExpressionWhenCalledAtPath(path: ObjectPath): ExpressionEntity { - if (path.length !== 1) return UNKNOWN_EXPRESSION; + getReturnExpressionWhenCalledAtPath( + path: ObjectPath + ): [expression: ExpressionEntity, isPure: boolean] { + if (path.length !== 1) return UNKNOWN_RETURN_EXPRESSION; return getMemberReturnExpressionWhenCalled(this.members, path[0]); } diff --git a/src/ast/nodes/LogicalExpression.ts b/src/ast/nodes/LogicalExpression.ts index 28bd3b5dd53..cbc8625e436 100644 --- a/src/ast/nodes/LogicalExpression.ts +++ b/src/ast/nodes/LogicalExpression.ts @@ -93,13 +93,26 @@ export default class LogicalExpression extends NodeBase implements Deoptimizable interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { const usedBranch = this.getUsedBranch(); if (!usedBranch) - return new MultiExpression([ - this.left.getReturnExpressionWhenCalledAtPath(path, interaction, recursionTracker, origin), - this.right.getReturnExpressionWhenCalledAtPath(path, interaction, recursionTracker, origin) - ]); + return [ + new MultiExpression([ + this.left.getReturnExpressionWhenCalledAtPath( + path, + interaction, + recursionTracker, + origin + )[0], + this.right.getReturnExpressionWhenCalledAtPath( + path, + interaction, + recursionTracker, + origin + )[0] + ]), + false + ]; this.expressionsToBeDeoptimized.push(origin); return usedBranch.getReturnExpressionWhenCalledAtPath( path, diff --git a/src/ast/nodes/MemberExpression.ts b/src/ast/nodes/MemberExpression.ts index b9137cd9199..6590b6aaabd 100644 --- a/src/ast/nodes/MemberExpression.ts +++ b/src/ast/nodes/MemberExpression.ts @@ -38,7 +38,7 @@ import type Super from './Super'; import { type ExpressionEntity, type LiteralValueOrUnknown, - UNKNOWN_EXPRESSION, + UNKNOWN_RETURN_EXPRESSION, UnknownValue } from './shared/Expression'; import { type ExpressionNode, type IncludeChildren, NodeBase } from './shared/Node'; @@ -197,7 +197,7 @@ export default class MemberExpression extends NodeBase implements DeoptimizableE interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { if (this.variable) { return this.variable.getReturnExpressionWhenCalledAtPath( path, @@ -207,7 +207,7 @@ export default class MemberExpression extends NodeBase implements DeoptimizableE ); } if (this.isUndefined) { - return UNDEFINED_EXPRESSION; + return [UNDEFINED_EXPRESSION, false]; } this.expressionsToBeDeoptimized.push(origin); if (path.length < MAX_PATH_DEPTH) { @@ -218,7 +218,7 @@ export default class MemberExpression extends NodeBase implements DeoptimizableE origin ); } - return UNKNOWN_EXPRESSION; + return UNKNOWN_RETURN_EXPRESSION; } hasEffects(context: HasEffectsContext): boolean { diff --git a/src/ast/nodes/ObjectExpression.ts b/src/ast/nodes/ObjectExpression.ts index 5ac588c45ab..31c13d146b2 100644 --- a/src/ast/nodes/ObjectExpression.ts +++ b/src/ast/nodes/ObjectExpression.ts @@ -59,7 +59,7 @@ export default class ObjectExpression extends NodeBase implements DeoptimizableE interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { return this.getObjectEntity().getReturnExpressionWhenCalledAtPath( path, interaction, diff --git a/src/ast/nodes/PropertyDefinition.ts b/src/ast/nodes/PropertyDefinition.ts index b4bcfc42876..6debb640378 100644 --- a/src/ast/nodes/PropertyDefinition.ts +++ b/src/ast/nodes/PropertyDefinition.ts @@ -11,7 +11,7 @@ import type PrivateIdentifier from './PrivateIdentifier'; import { type ExpressionEntity, type LiteralValueOrUnknown, - UNKNOWN_EXPRESSION, + UNKNOWN_RETURN_EXPRESSION, UnknownValue } from './shared/Expression'; import { type ExpressionNode, NodeBase } from './shared/Node'; @@ -50,10 +50,10 @@ export default class PropertyDefinition extends NodeBase { interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { return this.value ? this.value.getReturnExpressionWhenCalledAtPath(path, interaction, recursionTracker, origin) - : UNKNOWN_EXPRESSION; + : UNKNOWN_RETURN_EXPRESSION; } hasEffects(context: HasEffectsContext): boolean { diff --git a/src/ast/nodes/TaggedTemplateExpression.ts b/src/ast/nodes/TaggedTemplateExpression.ts index 15287b8f71d..70d3a84172a 100644 --- a/src/ast/nodes/TaggedTemplateExpression.ts +++ b/src/ast/nodes/TaggedTemplateExpression.ts @@ -12,7 +12,7 @@ import * as NodeType from './NodeType'; import type TemplateLiteral from './TemplateLiteral'; import CallExpressionBase from './shared/CallExpressionBase'; import type { ExpressionEntity } from './shared/Expression'; -import { UNKNOWN_EXPRESSION } from './shared/Expression'; +import { UNKNOWN_EXPRESSION, UNKNOWN_RETURN_EXPRESSION } from './shared/Expression'; import type { ExpressionNode, IncludeChildren } from './shared/Node'; export default class TaggedTemplateExpression extends CallExpressionBase { @@ -56,7 +56,7 @@ export default class TaggedTemplateExpression extends CallExpressionBase { this.quasi.include(context, includeChildrenRecursively); } this.tag.includeCallArguments(context, this.interaction.args); - const returnExpression = this.getReturnExpression(); + const [returnExpression] = this.getReturnExpression(); if (!returnExpression.included) { returnExpression.include(context, false); } @@ -94,9 +94,9 @@ export default class TaggedTemplateExpression extends CallExpressionBase { protected getReturnExpression( recursionTracker: PathTracker = SHARED_RECURSION_TRACKER - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { if (this.returnExpression === null) { - this.returnExpression = UNKNOWN_EXPRESSION; + this.returnExpression = UNKNOWN_RETURN_EXPRESSION; return (this.returnExpression = this.tag.getReturnExpressionWhenCalledAtPath( EMPTY_PATH, this.interaction, diff --git a/src/ast/nodes/TemplateLiteral.ts b/src/ast/nodes/TemplateLiteral.ts index a07d212ef9f..dee0b0ecaf4 100644 --- a/src/ast/nodes/TemplateLiteral.ts +++ b/src/ast/nodes/TemplateLiteral.ts @@ -12,7 +12,7 @@ import { import type * as NodeType from './NodeType'; import type TemplateElement from './TemplateElement'; import type { ExpressionEntity, LiteralValueOrUnknown } from './shared/Expression'; -import { UNKNOWN_EXPRESSION, UnknownValue } from './shared/Expression'; +import { UNKNOWN_RETURN_EXPRESSION, UnknownValue } from './shared/Expression'; import { type ExpressionNode, NodeBase } from './shared/Node'; export default class TemplateLiteral extends NodeBase { @@ -29,9 +29,11 @@ export default class TemplateLiteral extends NodeBase { return this.quasis[0].value.cooked; } - getReturnExpressionWhenCalledAtPath(path: ObjectPath): ExpressionEntity { + getReturnExpressionWhenCalledAtPath( + path: ObjectPath + ): [expression: ExpressionEntity, isPure: boolean] { if (path.length !== 1) { - return UNKNOWN_EXPRESSION; + return UNKNOWN_RETURN_EXPRESSION; } return getMemberReturnExpressionWhenCalled(literalStringMembers, path[0]); } diff --git a/src/ast/nodes/shared/CallExpressionBase.ts b/src/ast/nodes/shared/CallExpressionBase.ts index 0af6a709aac..f3b2c4b1985 100644 --- a/src/ast/nodes/shared/CallExpressionBase.ts +++ b/src/ast/nodes/shared/CallExpressionBase.ts @@ -11,19 +11,20 @@ import { type ExpressionEntity, type LiteralValueOrUnknown, UNKNOWN_EXPRESSION, + UNKNOWN_RETURN_EXPRESSION, UnknownValue } from './Expression'; import { NodeBase } from './Node'; export default abstract class CallExpressionBase extends NodeBase implements DeoptimizableEntity { protected declare interaction: NodeInteractionCalled; - protected returnExpression: ExpressionEntity | null = null; + protected returnExpression: [expression: ExpressionEntity, isPure: boolean] | null = null; private readonly deoptimizableDependentExpressions: DeoptimizableEntity[] = []; private readonly expressionsToBeDeoptimized = new Set(); deoptimizeCache(): void { - if (this.returnExpression !== UNKNOWN_EXPRESSION) { - this.returnExpression = UNKNOWN_EXPRESSION; + if (this.returnExpression?.[0] !== UNKNOWN_EXPRESSION) { + this.returnExpression = UNKNOWN_RETURN_EXPRESSION; for (const expression of this.deoptimizableDependentExpressions) { expression.deoptimizeCache(); } @@ -40,7 +41,7 @@ export default abstract class CallExpressionBase extends NodeBase implements Deo ) { return; } - const returnExpression = this.getReturnExpression(); + const [returnExpression] = this.getReturnExpression(); if (returnExpression !== UNKNOWN_EXPRESSION) { returnExpression.deoptimizePath(path); } @@ -51,7 +52,8 @@ export default abstract class CallExpressionBase extends NodeBase implements Deo path: ObjectPath, recursionTracker: PathTracker ): void { - const returnExpression = this.getReturnExpression(recursionTracker); + const [returnExpression, isPure] = this.getReturnExpression(recursionTracker); + if (isPure) return; if (returnExpression === UNKNOWN_EXPRESSION) { interaction.thisArg.deoptimizePath(UNKNOWN_PATH); } else { @@ -72,7 +74,7 @@ export default abstract class CallExpressionBase extends NodeBase implements Deo recursionTracker: PathTracker, origin: DeoptimizableEntity ): LiteralValueOrUnknown { - const returnExpression = this.getReturnExpression(recursionTracker); + const [returnExpression] = this.getReturnExpression(recursionTracker); if (returnExpression === UNKNOWN_EXPRESSION) { return UnknownValue; } @@ -92,24 +94,25 @@ export default abstract class CallExpressionBase extends NodeBase implements Deo interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { const returnExpression = this.getReturnExpression(recursionTracker); - if (this.returnExpression === UNKNOWN_EXPRESSION) { - return UNKNOWN_EXPRESSION; + if (returnExpression[0] === UNKNOWN_EXPRESSION) { + return returnExpression; } return recursionTracker.withTrackedEntityAtPath( path, returnExpression, () => { this.deoptimizableDependentExpressions.push(origin); - return returnExpression.getReturnExpressionWhenCalledAtPath( + const [expression, isPure] = returnExpression[0].getReturnExpressionWhenCalledAtPath( path, interaction, recursionTracker, origin ); + return [expression, isPure || returnExpression[1]]; }, - UNKNOWN_EXPRESSION + UNKNOWN_RETURN_EXPRESSION ); } @@ -138,8 +141,14 @@ export default abstract class CallExpressionBase extends NodeBase implements Deo ) { return false; } - return this.getReturnExpression().hasEffectsOnInteractionAtPath(path, interaction, context); + const [returnExpression, isPure] = this.getReturnExpression(); + return ( + (type === INTERACTION_ASSIGNED || !isPure) && + returnExpression.hasEffectsOnInteractionAtPath(path, interaction, context) + ); } - protected abstract getReturnExpression(recursionTracker?: PathTracker): ExpressionEntity; + protected abstract getReturnExpression( + recursionTracker?: PathTracker + ): [expression: ExpressionEntity, isPure: boolean]; } diff --git a/src/ast/nodes/shared/ClassNode.ts b/src/ast/nodes/shared/ClassNode.ts index 996520efef4..d9a7ab72c56 100644 --- a/src/ast/nodes/shared/ClassNode.ts +++ b/src/ast/nodes/shared/ClassNode.ts @@ -66,7 +66,7 @@ export default class ClassNode extends NodeBase implements DeoptimizableEntity { interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { return this.getObjectEntity().getReturnExpressionWhenCalledAtPath( path, interaction, diff --git a/src/ast/nodes/shared/Expression.ts b/src/ast/nodes/shared/Expression.ts index 7edd783c960..04a802d706d 100644 --- a/src/ast/nodes/shared/Expression.ts +++ b/src/ast/nodes/shared/Expression.ts @@ -59,8 +59,8 @@ export class ExpressionEntity implements WritableEntity { _interaction: NodeInteractionCalled, _recursionTracker: PathTracker, _origin: DeoptimizableEntity - ): ExpressionEntity { - return UNKNOWN_EXPRESSION; + ): [expression: ExpressionEntity, isPure: boolean] { + return UNKNOWN_RETURN_EXPRESSION; } hasEffectsOnInteractionAtPath( @@ -95,3 +95,8 @@ export class ExpressionEntity implements WritableEntity { export const UNKNOWN_EXPRESSION: ExpressionEntity = new (class UnknownExpression extends ExpressionEntity {})(); + +export const UNKNOWN_RETURN_EXPRESSION: [expression: ExpressionEntity, isPure: boolean] = [ + UNKNOWN_EXPRESSION, + false +]; diff --git a/src/ast/nodes/shared/FunctionBase.ts b/src/ast/nodes/shared/FunctionBase.ts index eb8095d98df..74aab33d9a8 100644 --- a/src/ast/nodes/shared/FunctionBase.ts +++ b/src/ast/nodes/shared/FunctionBase.ts @@ -23,7 +23,7 @@ import * as NodeType from '../NodeType'; import RestElement from '../RestElement'; import type SpreadElement from '../SpreadElement'; import type { ExpressionEntity, LiteralValueOrUnknown } from './Expression'; -import { UNKNOWN_EXPRESSION } from './Expression'; +import { UNKNOWN_EXPRESSION, UNKNOWN_RETURN_EXPRESSION } from './Expression'; import { type ExpressionNode, type GenericEsTreeNode, @@ -74,7 +74,7 @@ export default abstract class FunctionBase extends NodeBase { interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { if (path.length > 0) { return this.getObjectEntity().getReturnExpressionWhenCalledAtPath( path, @@ -89,9 +89,9 @@ export default abstract class FunctionBase extends NodeBase { this.scope.getReturnExpression().deoptimizePath(UNKNOWN_PATH); this.context.requestTreeshakingPass(); } - return UNKNOWN_EXPRESSION; + return UNKNOWN_RETURN_EXPRESSION; } - return this.scope.getReturnExpression(); + return [this.scope.getReturnExpression(), false]; } hasEffectsOnInteractionAtPath( diff --git a/src/ast/nodes/shared/MethodBase.ts b/src/ast/nodes/shared/MethodBase.ts index 10e8c6d7858..170019af869 100644 --- a/src/ast/nodes/shared/MethodBase.ts +++ b/src/ast/nodes/shared/MethodBase.ts @@ -22,7 +22,7 @@ import type PrivateIdentifier from '../PrivateIdentifier'; import { type ExpressionEntity, type LiteralValueOrUnknown, - UNKNOWN_EXPRESSION + UNKNOWN_RETURN_EXPRESSION } from './Expression'; import { type ExpressionNode, NodeBase } from './Node'; import type { PatternNode } from './Pattern'; @@ -33,14 +33,14 @@ export default class MethodBase extends NodeBase implements DeoptimizableEntity declare kind: 'constructor' | 'method' | 'init' | 'get' | 'set'; declare value: ExpressionNode | (ExpressionNode & PatternNode); - private accessedValue: ExpressionEntity | null = null; + private accessedValue: [expression: ExpressionEntity, isPure: boolean] | null = null; // As getter properties directly receive their values from fixed function // expressions, there is no known situation where a getter is deoptimized. deoptimizeCache(): void {} deoptimizePath(path: ObjectPath): void { - this.getAccessedValue().deoptimizePath(path); + this.getAccessedValue()[0].deoptimizePath(path); } deoptimizeThisOnInteractionAtPath( @@ -72,7 +72,11 @@ export default class MethodBase extends NodeBase implements DeoptimizableEntity recursionTracker ); } - this.getAccessedValue().deoptimizeThisOnInteractionAtPath(interaction, path, recursionTracker); + this.getAccessedValue()[0].deoptimizeThisOnInteractionAtPath( + interaction, + path, + recursionTracker + ); } getLiteralValueAtPath( @@ -80,7 +84,7 @@ export default class MethodBase extends NodeBase implements DeoptimizableEntity recursionTracker: PathTracker, origin: DeoptimizableEntity ): LiteralValueOrUnknown { - return this.getAccessedValue().getLiteralValueAtPath(path, recursionTracker, origin); + return this.getAccessedValue()[0].getLiteralValueAtPath(path, recursionTracker, origin); } getReturnExpressionWhenCalledAtPath( @@ -88,8 +92,8 @@ export default class MethodBase extends NodeBase implements DeoptimizableEntity interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { - return this.getAccessedValue().getReturnExpressionWhenCalledAtPath( + ): [expression: ExpressionEntity, isPure: boolean] { + return this.getAccessedValue()[0].getReturnExpressionWhenCalledAtPath( path, interaction, recursionTracker, @@ -131,15 +135,15 @@ export default class MethodBase extends NodeBase implements DeoptimizableEntity context ); } - return this.getAccessedValue().hasEffectsOnInteractionAtPath(path, interaction, context); + return this.getAccessedValue()[0].hasEffectsOnInteractionAtPath(path, interaction, context); } protected applyDeoptimizations() {} - protected getAccessedValue(): ExpressionEntity { + protected getAccessedValue(): [expression: ExpressionEntity, isPure: boolean] { if (this.accessedValue === null) { if (this.kind === 'get') { - this.accessedValue = UNKNOWN_EXPRESSION; + this.accessedValue = UNKNOWN_RETURN_EXPRESSION; return (this.accessedValue = this.value.getReturnExpressionWhenCalledAtPath( EMPTY_PATH, NODE_INTERACTION_UNKNOWN_CALL, @@ -147,7 +151,7 @@ export default class MethodBase extends NodeBase implements DeoptimizableEntity this )); } else { - return (this.accessedValue = this.value); + return (this.accessedValue = [this.value, false]); } } return this.accessedValue; diff --git a/src/ast/nodes/shared/MethodTypes.ts b/src/ast/nodes/shared/MethodTypes.ts index 3c54cd738ad..2facd9162e3 100644 --- a/src/ast/nodes/shared/MethodTypes.ts +++ b/src/ast/nodes/shared/MethodTypes.ts @@ -16,7 +16,7 @@ import { UNKNOWN_LITERAL_NUMBER, UNKNOWN_LITERAL_STRING } from '../../values'; -import { ExpressionEntity, UNKNOWN_EXPRESSION } from './Expression'; +import { ExpressionEntity, UNKNOWN_EXPRESSION, UNKNOWN_RETURN_EXPRESSION } from './Expression'; type MethodDescription = { callsArgs: number[] | null; @@ -49,16 +49,17 @@ export class Method extends ExpressionEntity { getReturnExpressionWhenCalledAtPath( path: ObjectPath, { thisArg }: NodeInteractionCalled - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { if (path.length > 0) { - return UNKNOWN_EXPRESSION; + return UNKNOWN_RETURN_EXPRESSION; } - return ( + return [ this.description.returnsPrimitive || - (this.description.returns === 'self' - ? thisArg || UNKNOWN_EXPRESSION - : this.description.returns()) - ); + (this.description.returns === 'self' + ? thisArg || UNKNOWN_EXPRESSION + : this.description.returns()), + false + ]; } hasEffectsOnInteractionAtPath( diff --git a/src/ast/nodes/shared/MultiExpression.ts b/src/ast/nodes/shared/MultiExpression.ts index 61369b12e1a..e5442452f10 100644 --- a/src/ast/nodes/shared/MultiExpression.ts +++ b/src/ast/nodes/shared/MultiExpression.ts @@ -22,12 +22,21 @@ export class MultiExpression extends ExpressionEntity { interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { - return new MultiExpression( - this.expressions.map(expression => - expression.getReturnExpressionWhenCalledAtPath(path, interaction, recursionTracker, origin) - ) - ); + ): [expression: ExpressionEntity, isPure: boolean] { + return [ + new MultiExpression( + this.expressions.map( + expression => + expression.getReturnExpressionWhenCalledAtPath( + path, + interaction, + recursionTracker, + origin + )[0] + ) + ), + false + ]; } hasEffectsOnInteractionAtPath( diff --git a/src/ast/nodes/shared/ObjectEntity.ts b/src/ast/nodes/shared/ObjectEntity.ts index b8b9986ff11..4ee27f728f9 100644 --- a/src/ast/nodes/shared/ObjectEntity.ts +++ b/src/ast/nodes/shared/ObjectEntity.ts @@ -18,6 +18,7 @@ import type { LiteralValueOrUnknown } from './Expression'; import { ExpressionEntity, UNKNOWN_EXPRESSION, + UNKNOWN_RETURN_EXPRESSION, UnknownTruthyValue, UnknownValue } from './Expression'; @@ -250,9 +251,9 @@ export class ObjectEntity extends ExpressionEntity { interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { if (path.length === 0) { - return UNKNOWN_EXPRESSION; + return UNKNOWN_RETURN_EXPRESSION; } const [key, ...subPath] = path; const expressionAtPath = this.getMemberExpressionAndTrackDeopt(key, origin); @@ -272,7 +273,7 @@ export class ObjectEntity extends ExpressionEntity { origin ); } - return UNKNOWN_EXPRESSION; + return UNKNOWN_RETURN_EXPRESSION; } hasEffectsOnInteractionAtPath( diff --git a/src/ast/nodes/shared/ObjectMember.ts b/src/ast/nodes/shared/ObjectMember.ts index 5204129407a..266540b0e30 100644 --- a/src/ast/nodes/shared/ObjectMember.ts +++ b/src/ast/nodes/shared/ObjectMember.ts @@ -42,7 +42,7 @@ export class ObjectMember extends ExpressionEntity { interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { return this.object.getReturnExpressionWhenCalledAtPath( [this.key, ...path], interaction, diff --git a/src/ast/utils/PathTracker.ts b/src/ast/utils/PathTracker.ts index 9a840c7545f..e91742152ec 100644 --- a/src/ast/utils/PathTracker.ts +++ b/src/ast/utils/PathTracker.ts @@ -13,7 +13,7 @@ export type ObjectPathKey = | typeof UnknownInteger | typeof SymbolToStringTag; -export type ObjectPath = ObjectPathKey[]; +export type ObjectPath = readonly ObjectPathKey[]; export const EMPTY_PATH: ObjectPath = []; export const UNKNOWN_PATH: ObjectPath = [UnknownKey]; // For deoptimizations, this means we are modifying an unknown property but did diff --git a/src/ast/values.ts b/src/ast/values.ts index b74f5152e72..bb0297421aa 100644 --- a/src/ast/values.ts +++ b/src/ast/values.ts @@ -6,7 +6,11 @@ import { NODE_INTERACTION_UNKNOWN_CALL } from './NodeInteractions'; import type { LiteralValue } from './nodes/Literal'; -import { ExpressionEntity, UNKNOWN_EXPRESSION } from './nodes/shared/Expression'; +import { + ExpressionEntity, + UNKNOWN_EXPRESSION, + UNKNOWN_RETURN_EXPRESSION +} from './nodes/shared/Expression'; import { EMPTY_PATH, type ObjectPath, @@ -52,11 +56,13 @@ const returnsUnknown: RawMemberDescription = { export const UNKNOWN_LITERAL_BOOLEAN: ExpressionEntity = new (class UnknownBoolean extends ExpressionEntity { - getReturnExpressionWhenCalledAtPath(path: ObjectPath): ExpressionEntity { + getReturnExpressionWhenCalledAtPath( + path: ObjectPath + ): [expression: ExpressionEntity, isPure: boolean] { if (path.length === 1) { return getMemberReturnExpressionWhenCalled(literalBooleanMembers, path[0]); } - return UNKNOWN_EXPRESSION; + return UNKNOWN_RETURN_EXPRESSION; } hasEffectsOnInteractionAtPath( @@ -83,11 +89,13 @@ const returnsBoolean: RawMemberDescription = { export const UNKNOWN_LITERAL_NUMBER: ExpressionEntity = new (class UnknownNumber extends ExpressionEntity { - getReturnExpressionWhenCalledAtPath(path: ObjectPath): ExpressionEntity { + getReturnExpressionWhenCalledAtPath( + path: ObjectPath + ): [expression: ExpressionEntity, isPure: boolean] { if (path.length === 1) { return getMemberReturnExpressionWhenCalled(literalNumberMembers, path[0]); } - return UNKNOWN_EXPRESSION; + return UNKNOWN_RETURN_EXPRESSION; } hasEffectsOnInteractionAtPath( @@ -114,11 +122,13 @@ const returnsNumber: RawMemberDescription = { export const UNKNOWN_LITERAL_STRING: ExpressionEntity = new (class UnknownString extends ExpressionEntity { - getReturnExpressionWhenCalledAtPath(path: ObjectPath): ExpressionEntity { + getReturnExpressionWhenCalledAtPath( + path: ObjectPath + ): [expression: ExpressionEntity, isPure: boolean] { if (path.length === 1) { return getMemberReturnExpressionWhenCalled(literalStringMembers, path[0]); } - return UNKNOWN_EXPRESSION; + return UNKNOWN_RETURN_EXPRESSION; } hasEffectsOnInteractionAtPath( @@ -277,7 +287,7 @@ export function hasMemberEffectWhenCalled( export function getMemberReturnExpressionWhenCalled( members: MemberDescriptions, memberName: ObjectPathKey -): ExpressionEntity { - if (typeof memberName !== 'string' || !members[memberName]) return UNKNOWN_EXPRESSION; - return members[memberName].returns; +): [expression: ExpressionEntity, isPure: boolean] { + if (typeof memberName !== 'string' || !members[memberName]) return UNKNOWN_RETURN_EXPRESSION; + return [members[memberName].returns, false]; } diff --git a/src/ast/variables/LocalVariable.ts b/src/ast/variables/LocalVariable.ts index 420763678a7..e8dffb2d88a 100644 --- a/src/ast/variables/LocalVariable.ts +++ b/src/ast/variables/LocalVariable.ts @@ -21,6 +21,7 @@ import { type ExpressionEntity, type LiteralValueOrUnknown, UNKNOWN_EXPRESSION, + UNKNOWN_RETURN_EXPRESSION, UnknownValue } from '../nodes/shared/Expression'; import type { Node } from '../nodes/shared/Node'; @@ -131,9 +132,9 @@ export default class LocalVariable extends Variable { interaction: NodeInteractionCalled, recursionTracker: PathTracker, origin: DeoptimizableEntity - ): ExpressionEntity { + ): [expression: ExpressionEntity, isPure: boolean] { if (this.isReassigned || !this.init) { - return UNKNOWN_EXPRESSION; + return UNKNOWN_RETURN_EXPRESSION; } return recursionTracker.withTrackedEntityAtPath( path, @@ -147,7 +148,7 @@ export default class LocalVariable extends Variable { origin ); }, - UNKNOWN_EXPRESSION + UNKNOWN_RETURN_EXPRESSION ); } diff --git a/src/rollup/types.d.ts b/src/rollup/types.d.ts index f40a3c8804b..c90dc7410b7 100644 --- a/src/rollup/types.d.ts +++ b/src/rollup/types.d.ts @@ -466,6 +466,7 @@ type TreeshakingPreset = 'smallest' | 'safest' | 'recommended'; export interface NormalizedTreeshakingOptions { annotations: boolean; correctVarValueBeforeDeclaration: boolean; + manualPureFunctions: readonly string[]; moduleSideEffects: HasModuleSideEffects; propertyReadSideEffects: boolean | 'always'; tryCatchDeoptimization: boolean; diff --git a/src/utils/options/normalizeInputOptions.ts b/src/utils/options/normalizeInputOptions.ts index 52b941b3812..500012a5ebd 100644 --- a/src/utils/options/normalizeInputOptions.ts +++ b/src/utils/options/normalizeInputOptions.ts @@ -8,6 +8,7 @@ import type { RollupBuild, WarningHandler } from '../../rollup/types'; +import { EMPTY_ARRAY } from '../blank'; import { ensureArray } from '../ensureArray'; import { error, errorInvalidOption, warnDeprecationWithOptions } from '../error'; import { resolve } from '../path'; @@ -255,6 +256,8 @@ const getTreeshake = (config: InputOptions): NormalizedInputOptions['treeshake'] return { annotations: configWithPreset.annotations !== false, correctVarValueBeforeDeclaration: configWithPreset.correctVarValueBeforeDeclaration === true, + manualPureFunctions: + (configWithPreset.manualPureFunctions as readonly string[] | undefined) ?? EMPTY_ARRAY, moduleSideEffects: getHasModuleSideEffects( configWithPreset.moduleSideEffects as ModuleSideEffectsOption | undefined ), diff --git a/src/utils/options/options.ts b/src/utils/options/options.ts index c85ef2dfd0c..bdb7c1c1206 100644 --- a/src/utils/options/options.ts +++ b/src/utils/options/options.ts @@ -11,6 +11,7 @@ import type { WarningHandler } from '../../rollup/types'; import { asyncFlatten } from '../asyncFlatten'; +import { EMPTY_ARRAY } from '../blank'; import { error, errorInvalidOption, errorUnknownOption } from '../error'; import { printQuotedStringList } from '../printStringList'; @@ -46,6 +47,7 @@ export const treeshakePresets: { recommended: { annotations: true, correctVarValueBeforeDeclaration: false, + manualPureFunctions: EMPTY_ARRAY, moduleSideEffects: () => true, propertyReadSideEffects: true, tryCatchDeoptimization: true, @@ -54,6 +56,7 @@ export const treeshakePresets: { safest: { annotations: true, correctVarValueBeforeDeclaration: true, + manualPureFunctions: EMPTY_ARRAY, moduleSideEffects: () => true, propertyReadSideEffects: true, tryCatchDeoptimization: true, @@ -62,6 +65,7 @@ export const treeshakePresets: { smallest: { annotations: true, correctVarValueBeforeDeclaration: false, + manualPureFunctions: EMPTY_ARRAY, moduleSideEffects: () => false, propertyReadSideEffects: false, tryCatchDeoptimization: false, diff --git a/src/utils/pureFunctions.ts b/src/utils/pureFunctions.ts new file mode 100644 index 00000000000..bbae413ecb2 --- /dev/null +++ b/src/utils/pureFunctions.ts @@ -0,0 +1,20 @@ +import type { NormalizedInputOptions } from '../rollup/types'; + +export const PureFunctionKey = Symbol('PureFunction'); + +export interface PureFunctions { + [pathSegment: string]: PureFunctions; + [PureFunctionKey]?: boolean; +} + +export const getPureFunctions = ({ treeshake }: NormalizedInputOptions): PureFunctions => { + const pureFunctions: PureFunctions = Object.create(null); + for (const functionName of treeshake ? treeshake.manualPureFunctions : []) { + let currentFunctions = pureFunctions; + for (const pathSegment of functionName.split('.')) { + currentFunctions = currentFunctions[pathSegment] ||= Object.create(null); + } + currentFunctions[PureFunctionKey] = true; + } + return pureFunctions; +}; diff --git a/test/form/samples/manual-pure-functions/_config.js b/test/form/samples/manual-pure-functions/_config.js new file mode 100644 index 00000000000..5b689a5ef87 --- /dev/null +++ b/test/form/samples/manual-pure-functions/_config.js @@ -0,0 +1,6 @@ +module.exports = { + description: 'allows to manually declare functions as pure by name', + options: { + treeshake: { manualPureFunctions: ['foo', 'bar.a'] } + } +}; diff --git a/test/form/samples/manual-pure-functions/_expected.js b/test/form/samples/manual-pure-functions/_expected.js new file mode 100644 index 00000000000..85b4bcc2aef --- /dev/null +++ b/test/form/samples/manual-pure-functions/_expected.js @@ -0,0 +1,15 @@ +const lib = { + a: () => { + console.log(); + return () => { + console.log(); + return () => { + console.log(); + return console.log; + } + } + } +}; + +lib(); // not removed +lib.b(); // not removed diff --git a/test/form/samples/manual-pure-functions/main.js b/test/form/samples/manual-pure-functions/main.js new file mode 100644 index 00000000000..4e1f1774424 --- /dev/null +++ b/test/form/samples/manual-pure-functions/main.js @@ -0,0 +1,27 @@ +import { lib as bar } from './other'; + +foo; // removed +foo(); // removed +foo.a; // removed +foo.a(); // removed +foo.a()(); // removed +foo.a().a; // removed +foo.a().a(); // removed +foo.a().a()(); // removed +foo.a().a().a; // removed +foo.a().a().a(); // removed + +bar(); // not removed +bar.b(); // not removed + +bar.a(); // removed +bar?.a(); // removed +bar.a.a; // removed +bar.a.a(); // removed +bar.a()(); //removed +bar.a().a; //removed +bar.a().a(); //removed +bar.a()()(); //removed +bar.a()().a; //removed +bar.a()().a(); //removed + diff --git a/test/form/samples/manual-pure-functions/other.js b/test/form/samples/manual-pure-functions/other.js new file mode 100644 index 00000000000..26d73ff481a --- /dev/null +++ b/test/form/samples/manual-pure-functions/other.js @@ -0,0 +1,12 @@ +export const lib = { + a: () => { + console.log(); + return () => { + console.log(); + return () => { + console.log(); + return console.log; + } + } + } +};