Skip to content

Commit

Permalink
feat: feature flags and configurable code remover (#1295)
Browse files Browse the repository at this point in the history
  • Loading branch information
Anber committed Jul 22, 2023
1 parent 7955724 commit b191f54
Show file tree
Hide file tree
Showing 14 changed files with 357 additions and 145 deletions.
7 changes: 7 additions & 0 deletions .changeset/slow-apples-repeat.md
@@ -0,0 +1,7 @@
---
'@linaria/babel-preset': patch
'@linaria/testkit': patch
'@linaria/utils': patch
---

New option `features` for fine-tuning the build and evaluation process.
4 changes: 4 additions & 0 deletions docs/CONFIGURATION.md
Expand Up @@ -268,6 +268,10 @@ module.exports = {
If you need to specify custom babel configuration, you can pass them here. These babel options will be used by Linaria when parsing and evaluating modules.
- `features: Record<string, FeatureFlag>`
A map of feature flags to enable/disable. See [Feature Flags](./FEATURE_FLAGS.md##feature-flags) for more information.
## `@linaria/babel-preset`
The preset pre-processes and evaluates the CSS. The bundler plugins use this preset under the hood. You also might want to use this preset if you import the components outside of the files handled by your bundler, such as on your server or in unit tests.
Expand Down
47 changes: 47 additions & 0 deletions docs/FEATURE_FLAGS.md
@@ -0,0 +1,47 @@
# Feature Flags

Feature flags are used to enable or disable specific features provided. The `features` option in the configuration allows you to control the availability of these features.

## Syntax for Specifying Flags

- `true`: Enables the feature for all files.
- `false`: Disables the feature for all files.
- `"glob"`: Enables the feature only for files that match the specified glob pattern.
- `["glob1", "glob2"]`: Enables the feature for files matching any of the specified glob patterns.
- `["glob1", "!glob2"]`: Enables the feature for files matching `glob1` but excludes files that match `glob2`.

# `dangerousCodeRemover` Feature

The `dangerousCodeRemover` is a flag that is enabled by default. It is designed to enhance the static evaluation of values that are interpolated in styles and to optimize the processing of styled-wrapped components during the build stage. This optimization is crucial for maintaining a stable and fast build process. It is important to note that the `dangerousCodeRemover` does not impact the runtime code; it solely focuses on the code used during the build.

## How It Works

During the build process, Linaria statically analyzes the CSS-in-JS codebase and evaluates the styles and values that are being interpolated. The `dangerousCodeRemover` steps in at this stage to remove potentially unsafe code, which includes code that might interact with browser-specific APIs, make HTTP requests, or perform other runtime-specific operations. By removing such code, the evaluation becomes more reliable, predictable, and efficient.

## Benefits

Enabling the `dangerousCodeRemover` feature provides several benefits:

1. **Stability**: The removal of potentially unsafe code ensures that the build process remains stable. It minimizes the chances of encountering build-time errors caused by unsupported browser APIs or non-static operations.

2. **Performance**: Removing unnecessary code results in faster build times. The build tool can efficiently process and evaluate the styles and components without unnecessary overhead, leading to quicker development cycles.

## Fine-Tuning the Removal

While the `dangerousCodeRemover` is highly effective at optimizing the build process, there may be cases where it becomes overly aggressive and removes code that is actually required for your specific use case. In such situations, you have the flexibility to fine-tune the behavior of the remover.

By leveraging the `features` option in the configuration, you can selectively disable the `dangerousCodeRemover` for specific files. This allows you to preserve valuable code that may not be safely evaluated during the build process.

### Example

Suppose you have a file named `specialComponent.js` that contains code that should not be deleted. By adding the following entry to your `features` configuration:

```js
{
features: {
dangerousCodeRemover: ["**/*", "!**/specialComponent.js"],
},
}
```

You are instructing Linaria to exclude the `specialComponent.js` file from the removal process. As a result, any code within this file that would have been removed by the `dangerousCodeRemover` will be retained in the build output.
152 changes: 12 additions & 140 deletions packages/babel/src/plugins/preeval.ts
Expand Up @@ -2,18 +2,15 @@
* This file is a babel preset used to transform files inside evaluators.
* It works the same as main `babel/extract` preset, but do not evaluate lazy dependencies.
*/
import type { BabelFile, NodePath, PluginObj } from '@babel/core';
import type { Identifier } from '@babel/types';
import type { BabelFile, PluginObj } from '@babel/core';

import { createCustomDebug } from '@linaria/logger';
import type { StrictOptions } from '@linaria/utils';
import {
JSXElementsRemover,
getFileIdx,
isUnnecessaryReactCall,
nonType,
removeWithRelated,
addIdentifierToLinariaPreval,
removeDangerousCode,
isFeatureEnabled,
} from '@linaria/utils';

import type { Core } from '../babel';
Expand All @@ -22,52 +19,9 @@ import processTemplateExpression from '../utils/processTemplateExpression';

export type PreevalOptions = Pick<
StrictOptions,
'classNameSlug' | 'displayName' | 'evaluate'
'classNameSlug' | 'displayName' | 'evaluate' | 'features'
>;

const isGlobal = (id: NodePath<Identifier>): boolean => {
if (!nonType(id)) {
return false;
}

const { scope } = id;
const { name } = id.node;
return !scope.hasBinding(name) && scope.hasGlobal(name);
};

const forbiddenGlobals = new Set([
'XMLHttpRequest',
'clearImmediate',
'clearInterval',
'clearTimeout',
'document',
'fetch',
'localStorage',
'location',
'navigator',
'sessionStorage',
'setImmediate',
'setInterval',
'setTimeout',
'window',
]);

const isBrowserGlobal = (id: NodePath<Identifier>) => {
return forbiddenGlobals.has(id.node.name) && isGlobal(id);
};

const getPropertyName = (path: NodePath): string | null => {
if (path.isIdentifier()) {
return path.node.name;
}

if (path.isStringLiteral()) {
return path.node.value;
}

return null;
};

export default function preeval(
babel: Core,
options: PreevalOptions
Expand All @@ -76,7 +30,8 @@ export default function preeval(
return {
name: '@linaria/babel/preeval',
pre(file: BabelFile) {
const log = createCustomDebug('preeval', getFileIdx(file.opts.filename!));
const filename = file.opts.filename!;
const log = createCustomDebug('preeval', getFileIdx(filename));

log('start', 'Looking for template literals…');

Expand All @@ -98,95 +53,12 @@ export default function preeval(
},
});

log('start', 'Strip all JSX and browser related stuff');
file.path.traverse(
{
// JSX can be replaced with a dummy value,
// but we have to do it after we processed template tags.
CallExpression: {
enter(p) {
if (isUnnecessaryReactCall(p)) {
JSXElementsRemover(p);
}
},
},
JSXElement: {
enter: JSXElementsRemover,
},
JSXFragment: {
enter: JSXElementsRemover,
},
MemberExpression(p, state) {
const obj = p.get('object');
const prop = p.get('property');
if (!obj.isIdentifier({ name: 'window' })) {
return;
}

const name = getPropertyName(prop);
if (!name) {
return;
}

state.windowScoped.add(name);
// eslint-disable-next-line no-param-reassign
state.globals = state.globals.filter((id) => {
if (id.node.name === name) {
removeWithRelated([id]);
return false;
}

return true;
});
},
MetaProperty(p) {
// Remove all references to `import.meta`
removeWithRelated([p]);
},
Identifier(p, state) {
if (p.find((parent) => parent.isTSTypeReference())) {
// don't mess with TS type references
return;
}
if (isBrowserGlobal(p)) {
if (
p.find(
(parentPath) =>
parentPath.isUnaryExpression({ operator: 'typeof' }) ||
parentPath.isTSTypeQuery()
)
) {
// Ignore `typeof window` expressions
return;
}

if (p.parentPath.isClassProperty()) {
// ignore class property decls
return;
}
if (p.parentPath.isMemberExpression() && p.key === 'property') {
// ignore e.g this.fetch()
// window.fetch will be handled by the windowScoped block below
return;
}

removeWithRelated([p]);

return;
}

if (state.windowScoped.has(p.node.name)) {
removeWithRelated([p]);
} else if (isGlobal(p)) {
state.globals.push(p);
}
},
},
{
globals: [] as NodePath<Identifier>[],
windowScoped: new Set<string>(),
}
);
if (
isFeatureEnabled(options.features, 'dangerousCodeRemover', filename)
) {
log('start', 'Strip all JSX and browser related stuff');
removeDangerousCode(file.path);
}
},
visitor: {},
post(file: BabelFile) {
Expand Down
20 changes: 16 additions & 4 deletions packages/babel/src/transform-stages/helpers/loadLinariaOptions.ts
Expand Up @@ -5,7 +5,7 @@ import type { StrictOptions } from '@linaria/utils';
import type { Stage } from '../../types';

export type PluginOptions = StrictOptions & {
configFile?: string;
configFile?: string | false;
stage?: Stage;
};

Expand Down Expand Up @@ -42,11 +42,18 @@ export default function loadLinariaOptions(
const { configFile, ignore, rules, babelOptions = {}, ...rest } = overrides;

const result =
configFile !== undefined
// eslint-disable-next-line no-nested-ternary
configFile === false
? undefined
: configFile !== undefined
? explorerSync.load(configFile)
: explorerSync.search();

const options = {
const defaultFeatures = {
dangerousCodeRemover: true,
};

const options: StrictOptions = {
displayName: false,
evaluate: true,
extensions: ['.cjs', '.cts', '.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx'],
Expand All @@ -73,8 +80,13 @@ export default function loadLinariaOptions(
},
],
babelOptions,
...(result ? result.config : null),
...(result ? result.config : {}),
...rest,
features: {
...defaultFeatures,
...(result ? result.config.features : {}),
...rest.features,
},
};

cache.set(overrides, options);
Expand Down
3 changes: 3 additions & 0 deletions packages/testkit/src/preeval.test.ts
Expand Up @@ -19,6 +19,9 @@ const run = (code: TemplateStringsArray) => {
preeval,
{
evaluate: true,
features: {
dangerousCodeRemover: true,
},
},
],
],
Expand Down
62 changes: 62 additions & 0 deletions packages/testkit/src/utils/isFeatureEnabled.test.ts
@@ -0,0 +1,62 @@
import type { FeatureFlag } from '@linaria/utils';
import { isFeatureEnabled } from '@linaria/utils';

describe('isFeatureEnabled', () => {
interface IFeatures {
foo?: FeatureFlag;
}

const file = '/some/path/to/file.ts';

const check = (obj: IFeatures) => isFeatureEnabled(obj, 'foo', file);

it.each<[result: 'disabled' | 'enabled', title: string, obj: IFeatures]>([
['disabled', 'undefined', {}],
['disabled', 'if explicitly disabled', { foo: false }],
['enabled', 'if explicitly enabled', { foo: true }],
['enabled', '*', { foo: '*' }],
['enabled', '**/*', { foo: '**/*' }],
['enabled', 'one of file matches', { foo: file }],
['enabled', 'match any file', { foo: ['/first.js', file, '/last.js'] }],
[
'disabled',
'nothing is matched',
{ foo: ['/first.js', '/second.js', '/last.js'] },
],
['enabled', 'file matches glob', { foo: '/some/**/*' }],
['disabled', 'file does not match glob', { foo: '/other/**/*' }],
[
'enabled',
'file matches one of globs',
{ foo: ['/other/**/*', '/some/**/*'] },
],
[
'disabled',
'file does not match any of globs',
{ foo: ['/other/**/*', '/another/**/*'] },
],
['disabled', 'file matches negated glob', { foo: '!/some/**/*' }],
[
'disabled',
'file does not match negated glob and there is no other rules',
{ foo: '!/other/**/*' },
],
[
'disabled',
'file does not match glob and matches negated glob',
{ foo: ['/other/**/*', '!/some/**/*'] },
],
[
'disabled',
'file does not match glob and matches negated glob',
{ foo: ['/other/**/*', '!/other/**/*'] },
],
[
'disabled',
'file matches matches glob and then negated glob',
{ foo: ['/some/**/*', '!**/*.ts'] },
],
])(`should be %s if %s`, (result, _, obj) => {
expect(check(obj)).toBe(result === 'enabled');
});
});
3 changes: 2 additions & 1 deletion packages/utils/package.json
Expand Up @@ -41,7 +41,8 @@
"@babel/types": "^7.22.5",
"@linaria/logger": "workspace:^",
"babel-merge": "^3.0.0",
"find-up": "^5.0.0"
"find-up": "^5.0.0",
"minimatch": "^9.0.3"
},
"devDependencies": {
"@types/babel__core": "^7.1.19",
Expand Down
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Expand Up @@ -31,6 +31,7 @@ export { isSerializable } from './isSerializable';
export { default as isTypedNode } from './isTypedNode';
export { default as isUnnecessaryReactCall } from './isUnnecessaryReactCall';
export * from './options';
export { removeDangerousCode } from './removeDangerousCode';
export {
applyAction,
mutate,
Expand Down
1 change: 1 addition & 0 deletions packages/utils/src/options/index.ts
@@ -1,3 +1,4 @@
export { default as buildOptions } from './buildOptions';
export { default as loadBabelOptions } from './loadBabelOptions';
export { isFeatureEnabled } from './isFeatureEnabled';
export * from './types';

0 comments on commit b191f54

Please sign in to comment.