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

fix: smarter handling of user's babel plugins & presets #613

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"build:esm": "babel src --out-dir esm --extensions '.js,.jsx,.ts,.tsx' --ignore '**/__tests__/**,**/__integration-tests__/**,**/__fixtures__/**' --source-maps --delete-dir-on-start",
"build": "yarn build:lib && yarn build:esm",
"build:declarations": "tsc --emitDeclarationOnly --outDir lib",
"watch": "yarn build --watch",
"watch": "yarn build:lib --watch",
"prepare": "yarn build && yarn build:declarations",
"release": "release-it",
"website": "yarn --cwd website",
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/detect-core-js.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ it('Ensures that package do not include core-js dependency after build', async (
expect(result).toContain(
'Based on your code and targets, core-js polyfills were not added'
);
});
}, 15000);
84 changes: 84 additions & 0 deletions src/__tests__/evaluators/buildOptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
mergeOrAppendPlugin,
mergeOrPrependPlugin,
} from '../../babel/evaluators/buildOptions';

describe('mergeOrPrependPlugin', () => {
it('when no duplicates, prepends the new target', () => {
const result = mergeOrPrependPlugin(
['bogus/already-in-plugins'],
'bogus/add-to-plugin'
);
expect(result).toEqual(['bogus/add-to-plugin', 'bogus/already-in-plugins']);
});

it('when duplicated, merges options, preferring the new plugin, and moves to the front', () => {
const result = mergeOrPrependPlugin(
[
'bogus/already-in-plugins',
[
'bogus/add-to-plugin',
{
existing: 'should not be overwritten',
overwrite: 'should be overwritten',
},
],
],
[
'bogus/add-to-plugin',
{ overwrite: 'was overwritten', added: 'was added' },
]
);
expect(result).toEqual([
[
'bogus/add-to-plugin',
{
existing: 'should not be overwritten',
overwrite: 'was overwritten',
added: 'was added',
},
],
'bogus/already-in-plugins',
]);
});
});

describe('mergeOrAppendPlugin', () => {
it('when no duplicates, appends the new target', () => {
const result = mergeOrAppendPlugin(
['bogus/already-in-plugins'],
'bogus/add-to-plugin'
);
expect(result).toEqual(['bogus/already-in-plugins', 'bogus/add-to-plugin']);
});

it('when duplicated, merges options, preferring the new plugin, and moves to the front', () => {
const result = mergeOrAppendPlugin(
[
[
'bogus/add-to-plugin',
{
existing: 'should not be overwritten',
overwrite: 'should be overwritten',
},
],
'bogus/already-in-plugins',
],
[
'bogus/add-to-plugin',
{ overwrite: 'was overwritten', added: 'was added' },
]
);
expect(result).toEqual([
'bogus/already-in-plugins',
[
'bogus/add-to-plugin',
{
existing: 'should not be overwritten',
overwrite: 'was overwritten',
added: 'was added',
},
],
]);
});
});
193 changes: 147 additions & 46 deletions src/babel/evaluators/buildOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
* This file handles preparing babel config for Linaria preevaluation.
*/

import { PluginItem, TransformOptions } from '@babel/core';
import {
PluginItem,
TransformOptions,
PluginTarget,
PluginOptions,
} from '@babel/core';
import { StrictOptions } from '../types';

type DefaultOptions = Partial<TransformOptions> & {
Expand All @@ -11,6 +16,125 @@ type DefaultOptions = Partial<TransformOptions> & {
caller: { evaluate: boolean };
};

function getPluginTarget(item: PluginItem) {
const target = Array.isArray(item) ? item[0] : item;
try {
if (typeof target === 'string') {
return require.resolve(target);
}
return target;
} catch {
return target;
}
}

function getPluginInstanceName(item: PluginItem) {
return Array.isArray(item) ? item[2] : undefined;
}

function shouldMergePlugins(left: PluginItem, right: PluginItem) {
const leftTarget = getPluginTarget(left);
const rightTarget = getPluginTarget(right);
const leftName = getPluginInstanceName(left);
const rightName = getPluginInstanceName(right);
const result = leftTarget === rightTarget && leftName === rightName;
return result;
}

function isMergablePlugin(
item: PluginItem
): item is
| PluginTarget
| [PluginTarget, PluginOptions]
| [PluginTarget, PluginOptions, string | undefined] {
return typeof item === 'string' || Array.isArray(item);
}

function getPluginOptions(item: PluginItem): object {
if (typeof item === 'string') {
return {};
}

if (Array.isArray(item)) {
return {
...item[1],
};
}

if (typeof item === 'object' && 'options' in item) {
return {
...item.options,
};
}

return {};
}

// Merge two plugin declarations. Options from the right override options
// on the left
function mergePluginItems(left: PluginItem, right: PluginItem) {
if (isMergablePlugin(left) && isMergablePlugin(right)) {
const pluginTarget = Array.isArray(left) ? left[0] : left;
const leftOptions = getPluginOptions(left);
const rightOptions = getPluginOptions(right);
return [pluginTarget, { ...leftOptions, ...rightOptions }];
}

return right;
}

/**
* Add `newItem` to the beginning of `plugins`. If a plugin with the same
* target (and ID) already exists, it will be dropeed, and its options
* merged into those of the newly added item.
*/
export function mergeOrPrependPlugin(
plugins: PluginItem[],
newItem: PluginItem
) {
const mergeItemIndex = plugins.findIndex(existingItem =>
shouldMergePlugins(existingItem, newItem)
);
if (mergeItemIndex === -1) {
return [newItem, ...plugins];
}

const mergedItem = mergePluginItems(plugins[mergeItemIndex], newItem);
return [
mergedItem,
...plugins.slice(0, mergeItemIndex),
...plugins.slice(mergeItemIndex + 1),
];
}

/**
* Add `newItem` to the end of `plugins`. If an item with the same target (and
* ID) already exists, instead update its options from `newItem`.
*/
export function mergeOrAppendPlugin(
plugins: PluginItem[],
newItem: PluginItem
) {
const mergeItemIndex = plugins.findIndex(existingItem =>
shouldMergePlugins(existingItem, newItem)
);
if (mergeItemIndex === -1) {
return [...plugins, newItem];
}

const mergedItem = mergePluginItems(plugins[mergeItemIndex], newItem);
return [
...plugins.slice(0, mergeItemIndex),
...plugins.slice(mergeItemIndex + 1),
mergedItem,
];
}

function isLinariaBabelPreset(item: PluginItem) {
const name = getPluginTarget(item);
return name === 'linaria/babel' || name === require.resolve('../../babel');
}

export default function buildOptions(
filename: string,
options?: StrictOptions
Expand Down Expand Up @@ -47,56 +171,33 @@ export default function buildOptions(
// If we programmatically pass babel options while there is a .babelrc, babel might throw
// We need to filter out duplicate presets and plugins so that this doesn't happen
// This workaround isn't full proof, but it's still better than nothing
const keys: Array<keyof TransformOptions & ('presets' | 'plugins')> = [
'presets',
'plugins',
];
keys.forEach(field => {
babelOptions[field] = babelOptions[field]
? babelOptions[field]!.filter((item: PluginItem) => {
// If item is an array it's a preset/plugin with options ([preset, options])
// Get the first item to get the preset.plugin name
// Otherwise it's a plugin name (can be a function too)
const name = Array.isArray(item) ? item[0] : item;

if (
// In our case, a preset might also be referring to linaria/babel
// We require the file from internal path which is not the same one that we export
// This case won't get caught and the preset won't filtered, even if they are same
// So we add an extra check for top level linaria/babel
name === 'linaria/babel' ||
name === require.resolve('../../babel') ||
// Also add a check for the plugin names we include for bundler support
plugins.includes(name)
) {
return false;
}

// Loop through the default presets/plugins to see if it already exists
return !defaults[field].some(it =>
// The default presets/plugins can also have nested arrays,
Array.isArray(it) ? it[0] === name : it === name
);
})
: [];
});
//
// In our case, a preset might also be referring to linaria/babel
// We require the file from internal path which is not the same one that we export
// This case won't get caught and the preset won't filtered, even if they are same
// So we add an extra check for top level linaria/babel
let configuredPresets = (babelOptions.presets || []).filter(
item => !isLinariaBabelPreset(item)
);
for (const requiredPreset of defaults.presets) {
// Preset order is last to first, so add our required presets to the end
// This makes sure that our preset is always run first
configuredPresets = mergeOrAppendPlugin(configuredPresets, requiredPreset);
}

let configuedPlugins = babelOptions.plugins || [];
for (const requiredPlugin of defaults.plugins.slice().reverse()) {
// Plugin order is first to last, so add our required plugins to the start
// This makes sure that the plugins we specify always run first
configuedPlugins = mergeOrPrependPlugin(configuedPlugins, requiredPlugin);
}

return {
// Passed options shouldn't be able to override the options we pass
// Linaria's plugins rely on these (such as filename to generate consistent hash)
...babelOptions,
...defaults,
presets: [
// Preset order is last to first, so add the extra presets to start
// This makes sure that our preset is always run first
...babelOptions.presets!,
...defaults.presets,
],
plugins: [
...defaults.plugins,
// Plugin order is first to last, so add the extra presets to end
// This makes sure that the plugins we specify always run first
...babelOptions.plugins!,
],
presets: configuredPresets,
plugins: configuedPlugins,
};
}
12 changes: 7 additions & 5 deletions src/babel/evaluators/extractor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import traverse, { NodePath } from '@babel/traverse';
import generator from '@babel/generator';

import { Evaluator } from '../../types';
import buildOptions from '../buildOptions';
import buildOptions, { mergeOrPrependPlugin } from '../buildOptions';
import RequirementsResolver from './RequirementsResolver';

// Checks that passed node is `exports.__linariaPreval = /* something */`
Expand Down Expand Up @@ -45,8 +45,11 @@ function isLinariaPrevalExport(

const extractor: Evaluator = (filename, options, text, only = null) => {
const transformOptions = buildOptions(filename, options);
transformOptions.presets!.unshift([require.resolve('../preeval'), options]);
transformOptions.plugins!.unshift([
transformOptions.presets = mergeOrPrependPlugin(transformOptions.presets!, [
require.resolve('../preeval'),
options,
]);
transformOptions.plugins = mergeOrPrependPlugin(transformOptions.plugins!, [
'@babel/plugin-transform-runtime',
{ useESModules: false },
]);
Expand All @@ -59,8 +62,7 @@ const extractor: Evaluator = (filename, options, text, only = null) => {
// Then Linaria can identify all `styled` as call expressions, including `styled.h1`, `styled.p` and others.

// Presets ordering is from last to first, so we add the plugin at the beginning of the list, which persist the order that was established with formerly used `@babel/preset-env`.

transformOptions.presets!.unshift({
transformOptions.presets = mergeOrPrependPlugin(transformOptions.presets!, {
plugins: ['@babel/plugin-transform-template-literals'],
});
// Expressions will be extracted only for __linariaPreval.
Expand Down
18 changes: 13 additions & 5 deletions src/babel/evaluators/shaker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { debug } from '../../utils/logger';
import generator from '@babel/generator';
import { Evaluator, StrictOptions } from '../../types';
import { transformSync, types } from '@babel/core';
import buildOptions from '../buildOptions';
import buildOptions, { mergeOrPrependPlugin } from '../buildOptions';

function prepareForShake(
filename: string,
Expand All @@ -13,17 +13,25 @@ function prepareForShake(
const transformOptions = buildOptions(filename, options);

transformOptions.ast = true;
transformOptions.presets!.unshift([

transformOptions.presets = mergeOrPrependPlugin(transformOptions.presets!, [
'@babel/preset-env',
{
targets: 'ie 11',
// we need this plugin so we list it explicitly, explanation in `evaluators/extractor/index`
include: ['@babel/plugin-transform-template-literals'],
},
]);
transformOptions.presets!.unshift([require.resolve('../preeval'), options]);
transformOptions.plugins!.unshift('transform-react-remove-prop-types');
transformOptions.plugins!.unshift([
transformOptions.presets = mergeOrPrependPlugin(transformOptions.presets!, [
require.resolve('../preeval'),
options,
]);

transformOptions.plugins = mergeOrPrependPlugin(
transformOptions.plugins!,
'transform-react-remove-prop-types'
);
transformOptions.plugins = mergeOrPrependPlugin(transformOptions.plugins!, [
'@babel/plugin-transform-runtime',
{ useESModules: false },
]);
Expand Down