Skip to content

Commit bcd6496

Browse files
antfulukastaegert
andauthored
feat: support #__NO_SIDE_EFFECTS__ annotation for function declaration (#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]>
1 parent 7bbbcb5 commit bcd6496

File tree

18 files changed

+370
-22
lines changed

18 files changed

+370
-22
lines changed

docs/configuration-options/index.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2021,7 +2021,11 @@ If you discover a bug caused by the tree-shaking algorithm, please file an issue
20212021
| CLI: | `--treeshake.annotations`/`--no-treeshake.annotations` |
20222022
| Default: | `true` |
20232023

2024-
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.
2024+
If `false`, ignore hints from annotation in comments:
2025+
2026+
##### `@__PURE__`
2027+
2028+
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.
20252029

20262030
```javascript
20272031
/*@__PURE__*/ console.log('side-effect');
@@ -2035,6 +2039,25 @@ class Impure {
20352039
/*@__PURE__*/ new Impure();
20362040
```
20372041
2042+
##### `@__NO_SIDE_EFFECTS__`
2043+
2044+
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.
2045+
2046+
```javascript
2047+
/*@__NO_SIDE_EFFECTS__*/
2048+
function impure() {
2049+
console.log('side-effect');
2050+
}
2051+
2052+
/*@__NO_SIDE_EFFECTS__*/
2053+
const impureArrowFn = () => {
2054+
console.log('side-effect');
2055+
};
2056+
2057+
impure(); // <-- call will be considered as side effect free
2058+
impureArrowFn(); // <-- call will be considered as side effect free
2059+
```
2060+
20382061
#### treeshake.correctVarValueBeforeDeclaration
20392062
20402063
| | |

src/Graph.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ import type {
1717
import { PluginDriver } from './utils/PluginDriver';
1818
import Queue from './utils/Queue';
1919
import { BuildPhase } from './utils/buildPhase';
20+
import { addAnnotations } from './utils/commentAnnotations';
2021
import {
2122
error,
2223
errorCircularDependency,
2324
errorImplicitDependantIsNotIncluded,
2425
errorMissingExport
2526
} from './utils/error';
2627
import { analyseModuleExecution } from './utils/executionOrder';
27-
import { addAnnotations } from './utils/pureComments';
2828
import { getPureFunctions } from './utils/pureFunctions';
2929
import type { PureFunctions } from './utils/pureFunctions';
3030
import { timeEnd, timeStart } from './utils/timers';

src/ast/nodes/ArrowFunctionExpression.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export default class ArrowFunctionExpression extends FunctionBase {
3838
): boolean {
3939
if (super.hasEffectsOnInteractionAtPath(path, interaction, context)) return true;
4040
if (interaction.type === INTERACTION_CALLED) {
41+
if (this.annotationNoSideEffects) {
42+
return false;
43+
}
44+
4145
const { ignore, brokenFlow } = context;
4246
context.ignore = {
4347
breaks: false,

src/ast/nodes/CallExpression.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type MagicString from 'magic-string';
2-
import type { NormalizedTreeshakingOptions } from '../../rollup/types';
32
import { BLANK } from '../../utils/blank';
43
import { errorCannotCallNamespace, errorEval } from '../../utils/error';
54
import { renderCallArguments } from '../../utils/renderCallArguments';
@@ -57,11 +56,9 @@ export default class CallExpression
5756
for (const argument of this.arguments) {
5857
if (argument.hasEffects(context)) return true;
5958
}
60-
if (
61-
(this.context.options.treeshake as NormalizedTreeshakingOptions).annotations &&
62-
this.annotations
63-
)
59+
if (this.annotationPure) {
6460
return false;
61+
}
6562
return (
6663
this.callee.hasEffects(context) ||
6764
this.callee.hasEffectsOnInteractionAtPath(EMPTY_PATH, this.interaction, context)

src/ast/nodes/NewExpression.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type MagicString from 'magic-string';
2-
import type { NormalizedTreeshakingOptions } from '../../rollup/types';
32
import { renderCallArguments } from '../../utils/renderCallArguments';
43
import type { RenderOptions } from '../../utils/renderHelpers';
54
import type { HasEffectsContext, InclusionContext } from '../ExecutionContext';
@@ -21,10 +20,7 @@ export default class NewExpression extends NodeBase {
2120
for (const argument of this.arguments) {
2221
if (argument.hasEffects(context)) return true;
2322
}
24-
if (
25-
(this.context.options.treeshake as NormalizedTreeshakingOptions).annotations &&
26-
this.annotations
27-
) {
23+
if (this.annotationPure) {
2824
return false;
2925
}
3026
return (

src/ast/nodes/shared/FunctionNode.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ export default class FunctionNode extends FunctionBase {
4545

4646
hasEffects(context: HasEffectsContext): boolean {
4747
if (!this.deoptimized) this.applyDeoptimizations();
48+
49+
if (this.annotationNoSideEffects) {
50+
return false;
51+
}
52+
4853
return !!this.id?.hasEffects(context);
4954
}
5055

@@ -54,6 +59,11 @@ export default class FunctionNode extends FunctionBase {
5459
context: HasEffectsContext
5560
): boolean {
5661
if (super.hasEffectsOnInteractionAtPath(path, interaction, context)) return true;
62+
63+
if (this.annotationNoSideEffects) {
64+
return false;
65+
}
66+
5767
if (interaction.type === INTERACTION_CALLED) {
5868
const thisInit = context.replacedVariableInits.get(this.scope.thisVariable);
5969
context.replacedVariableInits.set(

src/ast/nodes/shared/Node.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import type * as acorn from 'acorn';
22
import { locate, type Location } from 'locate-character';
33
import type MagicString from 'magic-string';
44
import type { AstContext } from '../../../Module';
5-
import { ANNOTATION_KEY, INVALID_COMMENT_KEY } from '../../../utils/pureComments';
5+
import type { NormalizedTreeshakingOptions } from '../../../rollup/types';
6+
import type { RollupAnnotation } from '../../../utils/commentAnnotations';
7+
import { ANNOTATION_KEY, INVALID_COMMENT_KEY } from '../../../utils/commentAnnotations';
68
import type { NodeRenderOptions, RenderOptions } from '../../../utils/renderHelpers';
79
import type { DeoptimizableEntity } from '../../DeoptimizableEntity';
810
import type { Entity } from '../../Entity';
@@ -124,7 +126,12 @@ export interface ChainElement extends ExpressionNode {
124126
}
125127

126128
export class NodeBase extends ExpressionEntity implements ExpressionNode {
127-
declare annotations?: acorn.Comment[];
129+
/** Marked with #__NO_SIDE_EFFECTS__ annotation */
130+
declare annotationNoSideEffects?: boolean;
131+
/** Marked with #__PURE__ annotation */
132+
declare annotationPure?: boolean;
133+
declare annotations?: RollupAnnotation[];
134+
128135
context: AstContext;
129136
declare end: number;
130137
esTreeNode: acorn.Node | null;
@@ -262,7 +269,14 @@ export class NodeBase extends ExpressionEntity implements ExpressionNode {
262269
if (this.hasOwnProperty(key)) continue;
263270
if (key.charCodeAt(0) === 95 /* _ */) {
264271
if (key === ANNOTATION_KEY) {
265-
this.annotations = value;
272+
const annotations = value as RollupAnnotation[];
273+
this.annotations = annotations;
274+
if ((this.context.options.treeshake as NormalizedTreeshakingOptions).annotations) {
275+
this.annotationNoSideEffects = annotations.some(
276+
comment => comment.annotationType === 'noSideEffects'
277+
);
278+
this.annotationPure = annotations.some(comment => comment.annotationType === 'pure');
279+
}
266280
} else if (key === INVALID_COMMENT_KEY) {
267281
for (const { start, end } of value as acorn.Comment[])
268282
this.context.magicString.remove(start, end);

src/utils/pureComments.ts renamed to src/utils/commentAnnotations.ts

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
11
import type * as acorn from 'acorn';
22
import { base as basicWalker } from 'acorn-walk';
33
import {
4+
ArrowFunctionExpression,
45
BinaryExpression,
56
CallExpression,
67
ChainExpression,
78
ConditionalExpression,
9+
ExportDefaultDeclaration,
10+
ExportNamedDeclaration,
811
ExpressionStatement,
12+
FunctionDeclaration,
913
LogicalExpression,
1014
NewExpression,
11-
SequenceExpression
15+
SequenceExpression,
16+
VariableDeclaration,
17+
VariableDeclarator
1218
} from '../ast/nodes/NodeType';
1319
import { SOURCEMAPPING_URL_RE } from './sourceMappingURL';
1420

21+
export type AnnotationType = 'noSideEffects' | 'pure';
22+
23+
export interface RollupAnnotation extends acorn.Comment {
24+
annotationType: AnnotationType;
25+
}
26+
1527
interface CommentState {
1628
annotationIndex: number;
17-
annotations: acorn.Comment[];
29+
annotations: RollupAnnotation[];
1830
code: string;
1931
}
2032

@@ -93,6 +105,28 @@ function markPureNode(node: NodeWithComments, comment: acorn.Comment, code: stri
93105
invalidAnnotation = true;
94106
break;
95107
}
108+
case ExportNamedDeclaration:
109+
case ExportDefaultDeclaration: {
110+
node = (node as any).declaration;
111+
continue;
112+
}
113+
case VariableDeclaration: {
114+
// case: /*#__PURE__*/ const foo = () => {}
115+
const declaration = node as any;
116+
if (declaration.kind === 'const') {
117+
// jsdoc only applies to the first declaration
118+
node = declaration.declarations[0].init;
119+
continue;
120+
}
121+
invalidAnnotation = true;
122+
break;
123+
}
124+
case VariableDeclarator: {
125+
node = (node as any).init;
126+
continue;
127+
}
128+
case FunctionDeclaration:
129+
case ArrowFunctionExpression:
96130
case CallExpression:
97131
case NewExpression: {
98132
break;
@@ -134,25 +168,32 @@ function doesNotMatchOutsideComment(code: string, forbiddenChars: RegExp): boole
134168
return true;
135169
}
136170

137-
const pureCommentRegex = /[#@]__PURE__/;
171+
const annotationsRegexes: [AnnotationType, RegExp][] = [
172+
['pure', /[#@]__PURE__/],
173+
['noSideEffects', /[#@]__NO_SIDE_EFFECTS__/]
174+
];
138175

139176
export function addAnnotations(
140177
comments: readonly acorn.Comment[],
141178
esTreeAst: acorn.Node,
142179
code: string
143180
): void {
144-
const annotations: acorn.Comment[] = [];
181+
const annotations: RollupAnnotation[] = [];
145182
const sourceMappingComments: acorn.Comment[] = [];
146183
for (const comment of comments) {
147-
if (pureCommentRegex.test(comment.value)) {
148-
annotations.push(comment);
149-
} else if (SOURCEMAPPING_URL_RE.test(comment.value)) {
184+
for (const [annotationType, regex] of annotationsRegexes) {
185+
if (regex.test(comment.value)) {
186+
annotations.push({ ...comment, annotationType });
187+
}
188+
}
189+
if (SOURCEMAPPING_URL_RE.test(comment.value)) {
150190
sourceMappingComments.push(comment);
151191
}
152192
}
153193
for (const comment of sourceMappingComments) {
154194
annotateNode(esTreeAst, comment, false);
155195
}
196+
156197
handlePureAnnotationsOfNode(esTreeAst, {
157198
annotationIndex: 0,
158199
annotations,
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// tests compiled from https://github.com/mishoo/UglifyJS2/blob/88c8f4e363e0d585b33ea29df560243d3dc74ce1/test/compress/pure_funcs.js
2+
3+
module.exports = defineTest({
4+
description: 'preserve __NO_SIDE_EFFECTS__ annotations for function declarations'
5+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*#__NO_SIDE_EFFECTS__*/
2+
function fnFromSub (args) {
3+
console.log(args);
4+
return args
5+
}
6+
7+
function fnPure(args) {
8+
return args
9+
}
10+
11+
function fnEffects(args) {
12+
console.log(args);
13+
return args
14+
}
15+
16+
/*#__NO_SIDE_EFFECTS__*/
17+
function fnA (args) {
18+
console.log(args);
19+
return args
20+
}
21+
22+
/*#__NO_SIDE_EFFECTS__*/
23+
function fnB (args) {
24+
console.log(args);
25+
return args
26+
}
27+
28+
const fnC = /*#__NO_SIDE_EFFECTS__*/ (args) => {
29+
console.log(args);
30+
return args
31+
};
32+
33+
34+
/*#__NO_SIDE_EFFECTS__*/
35+
const fnD = (args) => {
36+
console.log(args);
37+
return args
38+
};
39+
40+
/*#__NO_SIDE_EFFECTS__*/
41+
const fnE = (args) => {
42+
console.log(args);
43+
return args
44+
};
45+
46+
/**
47+
* This is a jsdoc comment, with no side effects annotation
48+
*
49+
* @param {any} args
50+
* @__NO_SIDE_EFFECTS__
51+
*/
52+
const fnF = (args) => {
53+
console.log(args);
54+
return args
55+
};
56+
57+
const fnAlias = fnA;
58+
59+
/**
60+
* Have both annotations
61+
*
62+
* @__PURE__
63+
* @__NO_SIDE_EFFECTS__
64+
*/
65+
const fnBothAnnotations = (args) => {
66+
console.log(args);
67+
return args
68+
};
69+
70+
// This annonation get ignored
71+
72+
let fnLet = (args) => {
73+
console.log(args);
74+
return args
75+
};
76+
77+
export { fnA, fnAlias, fnB, fnBothAnnotations, fnC, fnD, fnE, fnEffects, fnF, fnFromSub, fnLet, fnPure };

0 commit comments

Comments
 (0)