Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support #__NO_SIDE_EFFECTS__ annotation for function declaration #5024

Merged
merged 11 commits into from Jun 7, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
25 changes: 24 additions & 1 deletion docs/configuration-options/index.md
Expand Up @@ -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__`

For 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 treeshaken unless this option is set to `false`, in which case it will remain unchanged.

```javascript
/*@__PURE__*/ console.log('side-effect');
Expand All @@ -2035,6 +2039,25 @@ class Impure {
/*@__PURE__*/ new Impure();
```

##### `@__NO_SIDE_EFFECTS__`

For comments containing `@__NO_SIDE_EFFECTS__` or `#__NO_SIDE_EFFECTS__`, when determining side effects of function declaration. When a function been marked as no side effects, all calls to that function will be considered as no side effects. The following code will be completely treeshaken 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 no side effects
impureArrowFn(); // <-- call will be considered as no side effects
```

#### treeshake.correctVarValueBeforeDeclaration

| | |
Expand Down
2 changes: 1 addition & 1 deletion src/Graph.ts
Expand Up @@ -17,14 +17,14 @@ 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,
errorImplicitDependantIsNotIncluded,
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';
Expand Down
4 changes: 4 additions & 0 deletions src/ast/nodes/ArrowFunctionExpression.ts
Expand Up @@ -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,
Expand Down
7 changes: 2 additions & 5 deletions 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';
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 1 addition & 5 deletions 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';
Expand All @@ -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 (
Expand Down
10 changes: 10 additions & 0 deletions src/ast/nodes/shared/FunctionNode.ts
Expand Up @@ -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);
}

Expand All @@ -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(
Expand Down
20 changes: 17 additions & 3 deletions src/ast/nodes/shared/Node.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
55 changes: 48 additions & 7 deletions src/utils/pureComments.ts → 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;
}

Expand Down Expand Up @@ -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;
antfu marked this conversation as resolved.
Show resolved Hide resolved
break;
}
case VariableDeclarator: {
node = (node as any).init;
continue;
}
case FunctionDeclaration:
case ArrowFunctionExpression:
case CallExpression:
case NewExpression: {
break;
Expand Down Expand Up @@ -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,
Expand Down
@@ -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'
});
@@ -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 };