Skip to content

Commit

Permalink
feat: support #__NO_SIDE_EFFECTS__ annotation for function declarat…
Browse files Browse the repository at this point in the history
…ion (#5024)

* feat: init

* chore: update

* chore: updates

* chore: update

* test: add test for jsdoc

* chore: coverage

* feat: change the anntation to `__NO_SIDE_EFFECTS__`

* chore: coverage

* chore: update suggestions

* docs: add docs

* Slightly clarify wording

---------

Co-authored-by: Lukas Taegert-Atkinson <[email protected]>
  • Loading branch information
antfu and lukastaegert committed Jun 7, 2023
1 parent 7bbbcb5 commit bcd6496
Show file tree
Hide file tree
Showing 18 changed files with 370 additions and 22 deletions.
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__`

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');
Expand All @@ -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
| | |
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;
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 };

0 comments on commit bcd6496

Please sign in to comment.