Skip to content

Commit

Permalink
Add simple way to manually declare pure functions (#4718)
Browse files Browse the repository at this point in the history
* Add docs

* Extend types

* Implement manualPureFunctions option

* extract pure function check

* adapt docs

3.4.0-0

* Also mark nested functions as pure

* Add pureness flag to return expressions

* Make return expressions of pure functions pure

* Add, validate and test CLI options

* Extend docs
  • Loading branch information
lukastaegert committed Nov 27, 2022
1 parent 4ee9a20 commit 290b07d
Show file tree
Hide file tree
Showing 38 changed files with 443 additions and 130 deletions.
21 changes: 14 additions & 7 deletions cli/help.md
Expand Up @@ -19,11 +19,11 @@ Basic options:
-p, --plugin <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> ID for AMD module (default is anonymous)
--amd.autoId Generate the AMD ID based off the chunk name
--amd.basePath <prefix> Path to prepend to auto generated AMD ID
--amd.define <name> Function to use in place of `define`
--amd.forceJsExtensionForImports Use `.js` extension in AMD imports
--amd.id <id> ID for AMD module (default is anonymous)
--assetFileNames <pattern> Name pattern for emitted assets
--banner <text> Code to insert at top of bundle (outside wrapper)
--chunkFileNames <pattern> Name pattern for emitted secondary chunks
Expand All @@ -35,16 +35,21 @@ Basic options:
--no-esModule Do not add __esModule property
--exports <mode> 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 <text> Code to insert at end of bundle (outside wrapper)
--no-freeze Do not freeze namespace objects
--generatedCode <preset> 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 <text> Code to insert at top of bundle (inside wrapper)
--no-makeAbsoluteExternalsRelative Prevent normalization of external imports
--maxParallelFileOps <value> How many files to read in parallel
Expand All @@ -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 <names> 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 <number> 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 <files> Exclude files from being watched
--watch.include <files> Limit watching to specified files
--watch.onStart <cmd> Shell command to run on `"START"` event
--watch.onBundleStart <cmd> Shell command to run on `"BUNDLE_START"` event
--watch.onBundleEnd <cmd> Shell command to run on `"BUNDLE_END"` event
--watch.onBundleStart <cmd> Shell command to run on `"BUNDLE_START"` event
--watch.onEnd <cmd> Shell command to run on `"END"` event
--watch.onError <cmd> Shell command to run on `"ERROR"` event
--validate Validate output
--watch.onStart <cmd> Shell command to run on `"START"` event
--watch.skipWrite Do not write files to disk when watching

Examples:

Expand Down
19 changes: 13 additions & 6 deletions docs/01-command-line-reference.md
Expand Up @@ -350,11 +350,11 @@ Many options have command line equivalents. In those cases, any arguments passed
-p, --plugin <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> ID for AMD module (default is anonymous)
--amd.autoId Generate the AMD ID based off the chunk name
--amd.basePath <prefix> Path to prepend to auto generated AMD ID
--amd.define <name> Function to use in place of `define`
--amd.forceJsExtensionForImports Use `.js` extension in AMD imports
--amd.id <id> ID for AMD module (default is anonymous)
--assetFileNames <pattern> Name pattern for emitted assets
--banner <text> Code to insert at top of bundle (outside wrapper)
--chunkFileNames <pattern> Name pattern for emitted secondary chunks
Expand All @@ -372,10 +372,15 @@ Many options have command line equivalents. In those cases, any arguments passed
--footer <text> Code to insert at end of bundle (outside wrapper)
--no-freeze Do not freeze namespace objects
--generatedCode <preset> 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 <type> Handle default/namespace imports from AMD/CommonJS
--inlineDynamicImports Create single bundle when using dynamic imports
--no-interop Do not include interop block
--intro <text> Code to insert at top of bundle (inside wrapper)
--no-makeAbsoluteExternalsRelative Prevent normalization of external imports
--maxParallelFileOps <value> How many files to read in parallel
Expand All @@ -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 <names> 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 <number> 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 <files> Exclude files from being watched
--watch.include <files> Limit watching to specified files
--watch.onStart <cmd> Shell command to run on `"START"` event
--watch.onBundleStart <cmd> Shell command to run on `"BUNDLE_START"` event
--watch.onBundleEnd <cmd> Shell command to run on `"BUNDLE_END"` event
--watch.onBundleStart <cmd> Shell command to run on `"BUNDLE_START"` event
--watch.onEnd <cmd> Shell command to run on `"END"` event
--watch.onError <cmd> Shell command to run on `"ERROR"` event
--validate Validate output
--watch.onStart <cmd> 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.
Expand Down
31 changes: 31 additions & 0 deletions docs/999-big-list-of-options.md
Expand Up @@ -1706,6 +1706,37 @@ logBeforeDeclaration = true;
logIfEnabled(); // needs to be retained as it displays a log
```
**treeshake.manualPureFunctions**<br> Type: `string[]`<br> CLI: `--treeshake.manualPureFunctions <names>`
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**<br> Type: `boolean | "no-external" | string[] | (id: string, external: boolean) => boolean`<br> CLI: `--treeshake.moduleSideEffects`/`--no-treeshake.moduleSideEffects`/`--treeshake.moduleSideEffects no-external`<br> 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:
Expand Down
3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -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",
Expand Down
93 changes: 93 additions & 0 deletions 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);
}
}
4 changes: 4 additions & 0 deletions src/Graph.ts
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, true> = Object.create(null);
watchMode = false;
Expand Down Expand Up @@ -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<void> {
Expand Down
3 changes: 3 additions & 0 deletions src/Module.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/ast/nodes/ArrayExpression.ts
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions src/ast/nodes/CallExpression.ts
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 18 additions & 15 deletions src/ast/nodes/ConditionalExpression.ts
Expand Up @@ -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,
Expand Down

0 comments on commit 290b07d

Please sign in to comment.