diff --git a/docs/configuration-options/index.md b/docs/configuration-options/index.md index 27be0d86e49..5def299fed0 100755 --- a/docs/configuration-options/index.md +++ b/docs/configuration-options/index.md @@ -2021,7 +2021,11 @@ If you discover a bug caused by the tree-shaking algorithm, please file an issue | CLI: | `--treeshake.annotations`/`--no-treeshake.annotations` | | Default: | `true` | -If `false`, ignore hints from pure annotations, i.e. comments containing `@__PURE__` or `#__PURE__`, when determining side effects of function calls and constructor invocations. These annotations need to immediately precede the call invocation to take effect. The following code will be completely removed unless this option is set to `false`, in which case it will remain unchanged. +If `false`, ignore hints from annotation in comments: + +##### `@__PURE__` + +Comments containing `@__PURE__` or `#__PURE__` mark a specific function call or constructor invocation as side effect free. That means that Rollup will tree-shake i.e. remove the call unless the return value is used in some code that is not tree-shaken. These annotations need to immediately precede the call invocation to take effect. The following code will be completely tree-shaken unless this option is set to `false`, in which case it will remain unchanged. ```javascript /*@__PURE__*/ console.log('side-effect'); @@ -2035,6 +2039,25 @@ class Impure { /*@__PURE__*/ new Impure(); ``` +##### `@__NO_SIDE_EFFECTS__` + +Comments containing `@__NO_SIDE_EFFECTS__` or `#__NO_SIDE_EFFECTS__` mark a function declaration itself as side effect free. When a function has been marked as having no side effects, all calls to that function will be considered to be side effect free. The following code will be completely tree-shaken unless this option is set to `false`, in which case it will remain unchanged. + +```javascript +/*@__NO_SIDE_EFFECTS__*/ +function impure() { + console.log('side-effect'); +} + +/*@__NO_SIDE_EFFECTS__*/ +const impureArrowFn = () => { + console.log('side-effect'); +}; + +impure(); // <-- call will be considered as side effect free +impureArrowFn(); // <-- call will be considered as side effect free +``` + #### treeshake.correctVarValueBeforeDeclaration | | | diff --git a/src/Graph.ts b/src/Graph.ts index b212354965e..13313f4efe8 100644 --- a/src/Graph.ts +++ b/src/Graph.ts @@ -17,6 +17,7 @@ import type { import { PluginDriver } from './utils/PluginDriver'; import Queue from './utils/Queue'; import { BuildPhase } from './utils/buildPhase'; +import { addAnnotations } from './utils/commentAnnotations'; import { error, errorCircularDependency, @@ -24,7 +25,6 @@ import { errorMissingExport } 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'; diff --git a/src/ast/nodes/ArrowFunctionExpression.ts b/src/ast/nodes/ArrowFunctionExpression.ts index 2d47cad8f57..0179080f87b 100644 --- a/src/ast/nodes/ArrowFunctionExpression.ts +++ b/src/ast/nodes/ArrowFunctionExpression.ts @@ -38,6 +38,10 @@ export default class ArrowFunctionExpression extends FunctionBase { ): boolean { if (super.hasEffectsOnInteractionAtPath(path, interaction, context)) return true; if (interaction.type === INTERACTION_CALLED) { + if (this.annotationNoSideEffects) { + return false; + } + const { ignore, brokenFlow } = context; context.ignore = { breaks: false, diff --git a/src/ast/nodes/CallExpression.ts b/src/ast/nodes/CallExpression.ts index d50686fc1f8..689f6121421 100644 --- a/src/ast/nodes/CallExpression.ts +++ b/src/ast/nodes/CallExpression.ts @@ -1,5 +1,4 @@ import type MagicString from 'magic-string'; -import type { NormalizedTreeshakingOptions } from '../../rollup/types'; import { BLANK } from '../../utils/blank'; import { errorCannotCallNamespace, errorEval } from '../../utils/error'; import { renderCallArguments } from '../../utils/renderCallArguments'; @@ -57,11 +56,9 @@ export default class CallExpression for (const argument of this.arguments) { if (argument.hasEffects(context)) return true; } - if ( - (this.context.options.treeshake as NormalizedTreeshakingOptions).annotations && - this.annotations - ) + if (this.annotationPure) { return false; + } return ( this.callee.hasEffects(context) || this.callee.hasEffectsOnInteractionAtPath(EMPTY_PATH, this.interaction, context) diff --git a/src/ast/nodes/NewExpression.ts b/src/ast/nodes/NewExpression.ts index ccf7caa95ae..6c91ba90a6a 100644 --- a/src/ast/nodes/NewExpression.ts +++ b/src/ast/nodes/NewExpression.ts @@ -1,5 +1,4 @@ import type MagicString from 'magic-string'; -import type { NormalizedTreeshakingOptions } from '../../rollup/types'; import { renderCallArguments } from '../../utils/renderCallArguments'; import type { RenderOptions } from '../../utils/renderHelpers'; import type { HasEffectsContext, InclusionContext } from '../ExecutionContext'; @@ -21,10 +20,7 @@ export default class NewExpression extends NodeBase { for (const argument of this.arguments) { if (argument.hasEffects(context)) return true; } - if ( - (this.context.options.treeshake as NormalizedTreeshakingOptions).annotations && - this.annotations - ) { + if (this.annotationPure) { return false; } return ( diff --git a/src/ast/nodes/shared/FunctionNode.ts b/src/ast/nodes/shared/FunctionNode.ts index 3385ef2b37b..2d456407025 100644 --- a/src/ast/nodes/shared/FunctionNode.ts +++ b/src/ast/nodes/shared/FunctionNode.ts @@ -45,6 +45,11 @@ export default class FunctionNode extends FunctionBase { hasEffects(context: HasEffectsContext): boolean { if (!this.deoptimized) this.applyDeoptimizations(); + + if (this.annotationNoSideEffects) { + return false; + } + return !!this.id?.hasEffects(context); } @@ -54,6 +59,11 @@ export default class FunctionNode extends FunctionBase { context: HasEffectsContext ): boolean { if (super.hasEffectsOnInteractionAtPath(path, interaction, context)) return true; + + if (this.annotationNoSideEffects) { + return false; + } + if (interaction.type === INTERACTION_CALLED) { const thisInit = context.replacedVariableInits.get(this.scope.thisVariable); context.replacedVariableInits.set( diff --git a/src/ast/nodes/shared/Node.ts b/src/ast/nodes/shared/Node.ts index 6c7d26eefda..8a3827dcdb4 100644 --- a/src/ast/nodes/shared/Node.ts +++ b/src/ast/nodes/shared/Node.ts @@ -2,7 +2,9 @@ import type * as acorn from 'acorn'; import { locate, type Location } from 'locate-character'; import type MagicString from 'magic-string'; import type { AstContext } from '../../../Module'; -import { ANNOTATION_KEY, INVALID_COMMENT_KEY } from '../../../utils/pureComments'; +import type { NormalizedTreeshakingOptions } from '../../../rollup/types'; +import type { RollupAnnotation } from '../../../utils/commentAnnotations'; +import { ANNOTATION_KEY, INVALID_COMMENT_KEY } from '../../../utils/commentAnnotations'; import type { NodeRenderOptions, RenderOptions } from '../../../utils/renderHelpers'; import type { DeoptimizableEntity } from '../../DeoptimizableEntity'; import type { Entity } from '../../Entity'; @@ -124,7 +126,12 @@ export interface ChainElement extends ExpressionNode { } export class NodeBase extends ExpressionEntity implements ExpressionNode { - declare annotations?: acorn.Comment[]; + /** Marked with #__NO_SIDE_EFFECTS__ annotation */ + declare annotationNoSideEffects?: boolean; + /** Marked with #__PURE__ annotation */ + declare annotationPure?: boolean; + declare annotations?: RollupAnnotation[]; + context: AstContext; declare end: number; esTreeNode: acorn.Node | null; @@ -262,7 +269,14 @@ export class NodeBase extends ExpressionEntity implements ExpressionNode { if (this.hasOwnProperty(key)) continue; if (key.charCodeAt(0) === 95 /* _ */) { if (key === ANNOTATION_KEY) { - this.annotations = value; + const annotations = value as RollupAnnotation[]; + this.annotations = annotations; + if ((this.context.options.treeshake as NormalizedTreeshakingOptions).annotations) { + this.annotationNoSideEffects = annotations.some( + comment => comment.annotationType === 'noSideEffects' + ); + this.annotationPure = annotations.some(comment => comment.annotationType === 'pure'); + } } else if (key === INVALID_COMMENT_KEY) { for (const { start, end } of value as acorn.Comment[]) this.context.magicString.remove(start, end); diff --git a/src/utils/pureComments.ts b/src/utils/commentAnnotations.ts similarity index 77% rename from src/utils/pureComments.ts rename to src/utils/commentAnnotations.ts index dd6cd22b062..ad77451b495 100644 --- a/src/utils/pureComments.ts +++ b/src/utils/commentAnnotations.ts @@ -1,20 +1,32 @@ import type * as acorn from 'acorn'; import { base as basicWalker } from 'acorn-walk'; import { + ArrowFunctionExpression, BinaryExpression, CallExpression, ChainExpression, ConditionalExpression, + ExportDefaultDeclaration, + ExportNamedDeclaration, ExpressionStatement, + FunctionDeclaration, LogicalExpression, NewExpression, - SequenceExpression + SequenceExpression, + VariableDeclaration, + VariableDeclarator } from '../ast/nodes/NodeType'; import { SOURCEMAPPING_URL_RE } from './sourceMappingURL'; +export type AnnotationType = 'noSideEffects' | 'pure'; + +export interface RollupAnnotation extends acorn.Comment { + annotationType: AnnotationType; +} + interface CommentState { annotationIndex: number; - annotations: acorn.Comment[]; + annotations: RollupAnnotation[]; code: string; } @@ -93,6 +105,28 @@ function markPureNode(node: NodeWithComments, comment: acorn.Comment, code: stri invalidAnnotation = true; break; } + case ExportNamedDeclaration: + case ExportDefaultDeclaration: { + node = (node as any).declaration; + continue; + } + case VariableDeclaration: { + // case: /*#__PURE__*/ const foo = () => {} + const declaration = node as any; + if (declaration.kind === 'const') { + // jsdoc only applies to the first declaration + node = declaration.declarations[0].init; + continue; + } + invalidAnnotation = true; + break; + } + case VariableDeclarator: { + node = (node as any).init; + continue; + } + case FunctionDeclaration: + case ArrowFunctionExpression: case CallExpression: case NewExpression: { break; @@ -134,25 +168,32 @@ function doesNotMatchOutsideComment(code: string, forbiddenChars: RegExp): boole return true; } -const pureCommentRegex = /[#@]__PURE__/; +const annotationsRegexes: [AnnotationType, RegExp][] = [ + ['pure', /[#@]__PURE__/], + ['noSideEffects', /[#@]__NO_SIDE_EFFECTS__/] +]; export function addAnnotations( comments: readonly acorn.Comment[], esTreeAst: acorn.Node, code: string ): void { - const annotations: acorn.Comment[] = []; + const annotations: RollupAnnotation[] = []; const sourceMappingComments: acorn.Comment[] = []; for (const comment of comments) { - if (pureCommentRegex.test(comment.value)) { - annotations.push(comment); - } else if (SOURCEMAPPING_URL_RE.test(comment.value)) { + for (const [annotationType, regex] of annotationsRegexes) { + if (regex.test(comment.value)) { + annotations.push({ ...comment, annotationType }); + } + } + if (SOURCEMAPPING_URL_RE.test(comment.value)) { sourceMappingComments.push(comment); } } for (const comment of sourceMappingComments) { annotateNode(esTreeAst, comment, false); } + handlePureAnnotationsOfNode(esTreeAst, { annotationIndex: 0, annotations, diff --git a/test/form/samples/no-side-effects-function-declaration-preserve/_config.js b/test/form/samples/no-side-effects-function-declaration-preserve/_config.js new file mode 100644 index 00000000000..8791127a98b --- /dev/null +++ b/test/form/samples/no-side-effects-function-declaration-preserve/_config.js @@ -0,0 +1,5 @@ +// tests compiled from https://github.com/mishoo/UglifyJS2/blob/88c8f4e363e0d585b33ea29df560243d3dc74ce1/test/compress/pure_funcs.js + +module.exports = defineTest({ + description: 'preserve __NO_SIDE_EFFECTS__ annotations for function declarations' +}); diff --git a/test/form/samples/no-side-effects-function-declaration-preserve/_expected.js b/test/form/samples/no-side-effects-function-declaration-preserve/_expected.js new file mode 100644 index 00000000000..33772214ec3 --- /dev/null +++ b/test/form/samples/no-side-effects-function-declaration-preserve/_expected.js @@ -0,0 +1,77 @@ +/*#__NO_SIDE_EFFECTS__*/ +function fnFromSub (args) { + console.log(args); + return args +} + +function fnPure(args) { + return args +} + +function fnEffects(args) { + console.log(args); + return args +} + +/*#__NO_SIDE_EFFECTS__*/ +function fnA (args) { + console.log(args); + return args +} + +/*#__NO_SIDE_EFFECTS__*/ +function fnB (args) { + console.log(args); + return args +} + +const fnC = /*#__NO_SIDE_EFFECTS__*/ (args) => { + console.log(args); + return args +}; + + +/*#__NO_SIDE_EFFECTS__*/ +const fnD = (args) => { + console.log(args); + return args +}; + +/*#__NO_SIDE_EFFECTS__*/ +const fnE = (args) => { + console.log(args); + return args +}; + +/** + * This is a jsdoc comment, with no side effects annotation + * + * @param {any} args + * @__NO_SIDE_EFFECTS__ + */ +const fnF = (args) => { + console.log(args); + return args +}; + +const fnAlias = fnA; + +/** + * Have both annotations + * + * @__PURE__ + * @__NO_SIDE_EFFECTS__ + */ +const fnBothAnnotations = (args) => { + console.log(args); + return args +}; + +// This annonation get ignored + +let fnLet = (args) => { + console.log(args); + return args +}; + +export { fnA, fnAlias, fnB, fnBothAnnotations, fnC, fnD, fnE, fnEffects, fnF, fnFromSub, fnLet, fnPure }; diff --git a/test/form/samples/no-side-effects-function-declaration-preserve/functions.js b/test/form/samples/no-side-effects-function-declaration-preserve/functions.js new file mode 100644 index 00000000000..399b6a6c38a --- /dev/null +++ b/test/form/samples/no-side-effects-function-declaration-preserve/functions.js @@ -0,0 +1,80 @@ +export function fnPure(args) { + return args +} + +export function fnEffects(args) { + console.log(args) + return args +} + +/*#__NO_SIDE_EFFECTS__*/ +function fnA (args) { + console.log(args) + return args +} +export { fnA } + +/*#__NO_SIDE_EFFECTS__*/ +export function fnB (args) { + console.log(args) + return args +} + +export const fnC = /*#__NO_SIDE_EFFECTS__*/ (args) => { + console.log(args) + return args +} + + +/*#__NO_SIDE_EFFECTS__*/ +const fnD = (args) => { + console.log(args) + return args +} + +export { fnD } + +/*#__NO_SIDE_EFFECTS__*/ +export const fnE = (args) => { + console.log(args) + return args +} + +/** + * This is a jsdoc comment, with no side effects annotation + * + * @param {any} args + * @__NO_SIDE_EFFECTS__ + */ +export const fnF = (args) => { + console.log(args) + return args +} + +/*#__NO_SIDE_EFFECTS__*/ +export default function fnDefault(args) { + console.log(args) + return args +} + +export * from './sub-functions' + +export const fnAlias = fnA + +/** + * Have both annotations + * + * @__PURE__ + * @__NO_SIDE_EFFECTS__ + */ +export const fnBothAnnotations = (args) => { + console.log(args) + return args +} + +// This annonation get ignored +/** @__NO_SIDE_EFFECTS__ */ +export let fnLet = (args) => { + console.log(args) + return args +} diff --git a/test/form/samples/no-side-effects-function-declaration-preserve/main.js b/test/form/samples/no-side-effects-function-declaration-preserve/main.js new file mode 100644 index 00000000000..326e35231ee --- /dev/null +++ b/test/form/samples/no-side-effects-function-declaration-preserve/main.js @@ -0,0 +1 @@ +export * from './functions' diff --git a/test/form/samples/no-side-effects-function-declaration-preserve/sub-functions.js b/test/form/samples/no-side-effects-function-declaration-preserve/sub-functions.js new file mode 100644 index 00000000000..ed703deb9db --- /dev/null +++ b/test/form/samples/no-side-effects-function-declaration-preserve/sub-functions.js @@ -0,0 +1,5 @@ +/*#__NO_SIDE_EFFECTS__*/ +export function fnFromSub (args) { + console.log(args) + return args +} diff --git a/test/form/samples/no-side-effects-function-declaration/_config.js b/test/form/samples/no-side-effects-function-declaration/_config.js new file mode 100644 index 00000000000..c3178070cc2 --- /dev/null +++ b/test/form/samples/no-side-effects-function-declaration/_config.js @@ -0,0 +1,5 @@ +// tests compiled from https://github.com/mishoo/UglifyJS2/blob/88c8f4e363e0d585b33ea29df560243d3dc74ce1/test/compress/pure_funcs.js + +module.exports = defineTest({ + description: '__NO_SIDE_EFFECTS__ annotations for function declarations' +}); diff --git a/test/form/samples/no-side-effects-function-declaration/_expected.js b/test/form/samples/no-side-effects-function-declaration/_expected.js new file mode 100644 index 00000000000..bc3b94942f6 --- /dev/null +++ b/test/form/samples/no-side-effects-function-declaration/_expected.js @@ -0,0 +1,6 @@ +function fnEffects(args) { + console.log(args); + return args +} + +fnEffects(2); diff --git a/test/form/samples/no-side-effects-function-declaration/functions.js b/test/form/samples/no-side-effects-function-declaration/functions.js new file mode 100644 index 00000000000..effd3b96950 --- /dev/null +++ b/test/form/samples/no-side-effects-function-declaration/functions.js @@ -0,0 +1,62 @@ +export function fnPure(args) { + return args +} + +export function fnEffects(args) { + console.log(args) + return args +} + +/*#__NO_SIDE_EFFECTS__*/ +function fnA (args) { + console.log(args) + return args +} +export { fnA } + +/*#__NO_SIDE_EFFECTS__*/ +export function fnB (args) { + console.log(args) + return args +} + +export const fnC = /*#__NO_SIDE_EFFECTS__*/ (args) => { + console.log(args) + return args +} + + +/*#__NO_SIDE_EFFECTS__*/ +const fnD = (args) => { + console.log(args) + return args +} + +export { fnD } + +/*#__NO_SIDE_EFFECTS__*/ +export const fnE = (args) => { + console.log(args) + return args +} + +/** + * This is a jsdoc comment, with pure annotation + * + * @param {any} args + * @__NO_SIDE_EFFECTS__ + */ +export const fnF = (args) => { + console.log(args) + return args +} + +/*#__NO_SIDE_EFFECTS__*/ +export default function fnDefault(args) { + console.log(args) + return args +} + +export * from './sub-functions' + +export const fnAlias = fnA diff --git a/test/form/samples/no-side-effects-function-declaration/main.js b/test/form/samples/no-side-effects-function-declaration/main.js new file mode 100644 index 00000000000..f3eaed4f998 --- /dev/null +++ b/test/form/samples/no-side-effects-function-declaration/main.js @@ -0,0 +1,17 @@ +import fnDefault, { fnPure, fnEffects, fnA, fnB, fnC, fnD, fnE, fnF, fnAlias, fnFromSub } from './functions' + +const pure = fnPure(1) +const effects = fnEffects(2) + +const a = fnA(1) +const b = fnB(2) +const c = fnC(3) +const d = fnD(4) +const e = fnE(5) +const f = fnF(6) + +const defaults = fnDefault(3) +const alias = fnAlias(6) +const fromSub = fnFromSub(7) + +const _ = /*#__PURE__*/ fnEffects(1) diff --git a/test/form/samples/no-side-effects-function-declaration/sub-functions.js b/test/form/samples/no-side-effects-function-declaration/sub-functions.js new file mode 100644 index 00000000000..ed703deb9db --- /dev/null +++ b/test/form/samples/no-side-effects-function-declaration/sub-functions.js @@ -0,0 +1,5 @@ +/*#__NO_SIDE_EFFECTS__*/ +export function fnFromSub (args) { + console.log(args) + return args +}