From 4671e4cac81b77c9334e9c810d7e7214b408c636 Mon Sep 17 00:00:00 2001 From: Eugene Kashida Date: Fri, 11 Oct 2019 02:07:36 +0900 Subject: [PATCH] feat(features): add support for unary negation (#1562) --- .../@lwc/features/src/__tests__/flags.spec.ts | 151 ++++++++++++------ .../@lwc/features/src/babel-plugin/index.js | 135 ++++++++-------- 2 files changed, 174 insertions(+), 112 deletions(-) diff --git a/packages/@lwc/features/src/__tests__/flags.spec.ts b/packages/@lwc/features/src/__tests__/flags.spec.ts index 7c97981fd0..5160a144b5 100644 --- a/packages/@lwc/features/src/__tests__/flags.spec.ts +++ b/packages/@lwc/features/src/__tests__/flags.spec.ts @@ -12,14 +12,21 @@ const nonProdTests = { code: ` import featureFlags from '@lwc/features'; if (featureFlags.ENABLE_FEATURE_TRUE) { - console.log('ENABLE_FEATURE_TRUE'); + console.log('featureFlags.ENABLE_FEATURE_TRUE'); + } + if (!featureFlags.ENABLE_FEATURE_TRUE) { + console.log('!featureFlags.ENABLE_FEATURE_TRUE'); } `, output: ` import featureFlags, { runtimeFlags } from '@lwc/features'; if (runtimeFlags.ENABLE_FEATURE_TRUE) { - console.log('ENABLE_FEATURE_TRUE'); + console.log('featureFlags.ENABLE_FEATURE_TRUE'); + } + + if (!runtimeFlags.ENABLE_FEATURE_TRUE) { + console.log('!featureFlags.ENABLE_FEATURE_TRUE'); } `, }, @@ -27,14 +34,21 @@ const nonProdTests = { code: ` import features from '@lwc/features'; if (features.ENABLE_FEATURE_FALSE) { - console.log('ENABLE_FEATURE_FALSE'); + console.log('features.ENABLE_FEATURE_FALSE'); + } + if (!features.ENABLE_FEATURE_FALSE) { + console.log('!features.ENABLE_FEATURE_FALSE'); } `, output: ` import features, { runtimeFlags } from '@lwc/features'; if (runtimeFlags.ENABLE_FEATURE_FALSE) { - console.log('ENABLE_FEATURE_FALSE'); + console.log('features.ENABLE_FEATURE_FALSE'); + } + + if (!runtimeFlags.ENABLE_FEATURE_FALSE) { + console.log('!features.ENABLE_FEATURE_FALSE'); } `, }, @@ -42,18 +56,25 @@ const nonProdTests = { code: ` import features from '@lwc/features'; if (features.ENABLE_FEATURE_NULL) { - console.log('ENABLE_FEATURE_NULL'); + console.log('features.ENABLE_FEATURE_NULL'); + } + if (!features.ENABLE_FEATURE_NULL) { + console.log('!features.ENABLE_FEATURE_NULL'); } `, output: ` import features, { runtimeFlags } from '@lwc/features'; if (runtimeFlags.ENABLE_FEATURE_NULL) { - console.log('ENABLE_FEATURE_NULL'); + console.log('features.ENABLE_FEATURE_NULL'); + } + + if (!runtimeFlags.ENABLE_FEATURE_NULL) { + console.log('!features.ENABLE_FEATURE_NULL'); } `, }, - 'should not transform feature flags unless the if-test is a simple member expression': { + 'should not transform tests that are not a member expression or not a negated member expression (compile time)': { code: ` import FEATURES from '@lwc/features'; if (FEATURES.ENABLE_FEATURE_NULL === null) { @@ -62,9 +83,6 @@ const nonProdTests = { if (isTrue(FEATURES.ENABLE_FEATURE_TRUE)) { console.log('isTrue(ENABLE_FEATURE_TRUE)'); } - if (!FEATURES.ENABLE_FEATURE_FALSE) { - console.log('!ENABLE_FEATURE_FALSE'); - } `, output: ` import FEATURES, { runtimeFlags } from '@lwc/features'; @@ -76,22 +94,21 @@ const nonProdTests = { if (isTrue(FEATURES.ENABLE_FEATURE_TRUE)) { console.log('isTrue(ENABLE_FEATURE_TRUE)'); } - - if (!FEATURES.ENABLE_FEATURE_FALSE) { - console.log('!ENABLE_FEATURE_FALSE'); - } `, }, 'should not transform tests that are not an actual reference to the imported binding': { code: ` import featureFlag from '@lwc/features'; if (featureFlag.ENABLE_FEATURE_FALSE) { - console.log('ENABLE_FEATURE_FALSE'); + console.log('featureFlag.ENABLE_FEATURE_FALSE'); } function awesome() { const featureFlag = { ENABLE_FEATURE_FALSE: false }; if (featureFlag.ENABLE_FEATURE_FALSE) { - console.log('ENABLE_FEATURE_FALSE'); + console.log('featureFlag.ENABLE_FEATURE_FALSE'); + } + if (!featureFlag.ENABLE_FEATURE_FALSE) { + console.log('!featureFlag.ENABLE_FEATURE_FALSE'); } } `, @@ -99,7 +116,7 @@ const nonProdTests = { import featureFlag, { runtimeFlags } from '@lwc/features'; if (runtimeFlags.ENABLE_FEATURE_FALSE) { - console.log('ENABLE_FEATURE_FALSE'); + console.log('featureFlag.ENABLE_FEATURE_FALSE'); } function awesome() { @@ -108,7 +125,11 @@ const nonProdTests = { }; if (featureFlag.ENABLE_FEATURE_FALSE) { - console.log('ENABLE_FEATURE_FALSE'); + console.log('featureFlag.ENABLE_FEATURE_FALSE'); + } + + if (!featureFlag.ENABLE_FEATURE_FALSE) { + console.log('!featureFlag.ENABLE_FEATURE_FALSE'); } } `, @@ -130,6 +151,9 @@ const nonProdTests = { if (featureFlags.ENABLE_FEATURE_TRUE) { console.log('this looks like a bad idea'); } + if (!featureFlags.ENABLE_FEATURE_TRUE) { + console.log('this looks like a bad idea'); + } } `, output: ` @@ -139,6 +163,29 @@ const nonProdTests = { if (runtimeFlags.ENABLE_FEATURE_TRUE) { console.log('this looks like a bad idea'); } + + if (!runtimeFlags.ENABLE_FEATURE_TRUE) { + console.log('this looks like a bad idea'); + } + } + `, + }, + 'should throw an error if the flag is undefined': { + error: 'Invalid feature flag "ENABLE_THE_BEER". Flag is undefined.', + code: ` + import featureFlags from '@lwc/features'; + if (featureFlags.ENABLE_THE_BEER) { + console.log('featureFlags.ENABLE_THE_BEER'); + } + `, + }, + 'should throw an error if the flag name is formatted incorrectly': { + error: + 'Invalid feature flag "enable_the_beer". Flag name must only be composed of uppercase letters and underscores.', + code: ` + import featureFlags from '@lwc/features'; + if (featureFlags.enable_the_beer) { + console.log('featureFlags.enable_the_beer'); } `, }, @@ -148,7 +195,6 @@ const featureFlags = { ENABLE_FEATURE_TRUE: true, ENABLE_FEATURE_FALSE: false, ENABLE_FEATURE_NULL: null, - invalidFeatureFlag: true, // invalid because it's not all uppercase }; const babelOptions = { @@ -173,13 +219,16 @@ const nonProdTestOverrides = { code: ` import features from '@lwc/features'; if (features.ENABLE_FEATURE_TRUE) { - console.log('ENABLE_FEATURE_TRUE'); + console.log('features.ENABLE_FEATURE_TRUE'); + } + if (!features.ENABLE_FEATURE_TRUE) { + console.log('!features.ENABLE_FEATURE_TRUE'); } `, output: ` import features, { runtimeFlags } from '@lwc/features'; { - console.log('ENABLE_FEATURE_TRUE'); + console.log('features.ENABLE_FEATURE_TRUE'); } `, }, @@ -187,11 +236,17 @@ const nonProdTestOverrides = { code: ` import features from '@lwc/features'; if (features.ENABLE_FEATURE_FALSE) { - console.log('ENABLE_FEATURE_FALSE'); + console.log('features.ENABLE_FEATURE_FALSE'); + } + if (!features.ENABLE_FEATURE_FALSE) { + console.log('!features.ENABLE_FEATURE_FALSE'); } `, output: ` import features, { runtimeFlags } from '@lwc/features'; + { + console.log('!features.ENABLE_FEATURE_FALSE'); + } `, }, 'should transform nested feature flags': { @@ -233,7 +288,10 @@ const nonProdTestOverrides = { function awesome() { const featureFlag = { ENABLE_FEATURE_FALSE: false }; if (featureFlag.ENABLE_FEATURE_FALSE) { - console.log('ENABLE_FEATURE_FALSE'); + console.log('featureFlag.ENABLE_FEATURE_FALSE'); + } + if (!featureFlag.ENABLE_FEATURE_FALSE) { + console.log('!featureFlag.ENABLE_FEATURE_FALSE'); } } `, @@ -246,7 +304,11 @@ const nonProdTestOverrides = { }; if (featureFlag.ENABLE_FEATURE_FALSE) { - console.log('ENABLE_FEATURE_FALSE'); + console.log('featureFlag.ENABLE_FEATURE_FALSE'); + } + + if (!featureFlag.ENABLE_FEATURE_FALSE) { + console.log('!featureFlag.ENABLE_FEATURE_FALSE'); } } `, @@ -261,33 +323,36 @@ pluginTester({ prod: true, }, babelOptions, - tests: Object.assign({}, nonProdTests, nonProdTestOverrides, { + tests: { + ...nonProdTests, + ...nonProdTestOverrides, 'should transform both boolean and null feature flags': { code: ` import features from '@lwc/features'; if (features.ENABLE_FEATURE_TRUE) { - console.log('ENABLE_FEATURE_TRUE'); + console.log('features.ENABLE_FEATURE_TRUE'); } if (features.ENABLE_FEATURE_FALSE) { - console.log('ENABLE_FEATURE_FALSE'); + console.log('features.ENABLE_FEATURE_FALSE'); } if (features.ENABLE_FEATURE_NULL) { - console.log('ENABLE_FEATURE_NULL'); + console.log('features.ENABLE_FEATURE_NULL'); } `, output: ` import features, { runtimeFlags } from '@lwc/features'; { - console.log('ENABLE_FEATURE_TRUE'); + console.log('features.ENABLE_FEATURE_TRUE'); } if (runtimeFlags.ENABLE_FEATURE_NULL) { - console.log('ENABLE_FEATURE_NULL'); + console.log('features.ENABLE_FEATURE_NULL'); } `, }, 'should transform runtime flag lookups into compile-time flags': { code: ` + import { runtimeFlags } from '@lwc/features'; if (runtimeFlags.ENABLE_FEATURE_TRUE) { console.log('runtimeFlags.ENABLE_FEATURE_TRUE'); } @@ -299,6 +364,7 @@ pluginTester({ } `, output: ` + import { runtimeFlags } from '@lwc/features'; { console.log('runtimeFlags.ENABLE_FEATURE_TRUE'); } @@ -308,19 +374,19 @@ pluginTester({ } `, }, - 'should not transform runtime flag lookups unless the if-test is a member expression': { + 'should not transform tests that are not a member expression or not a negated member expression (runtime)': { code: ` + import { runtimeFlags } from '@lwc/features'; if (runtimeFlags.ENABLE_FEATURE_NULL === null) { console.log('runtimeFlags.ENABLE_FEATURE_NULL === null'); } if (isTrue(runtimeFlags.ENABLE_FEATURE_TRUE)) { console.log('runtimeFlags.ENABLE_FEATURE_TRUE'); } - if (!runtimeFlags.ENABLE_FEATURE_FALSE) { - console.log('!runtimeFlags.ENABLE_FEATURE_FALSE'); - } `, output: ` + import { runtimeFlags } from '@lwc/features'; + if (runtimeFlags.ENABLE_FEATURE_NULL === null) { console.log('runtimeFlags.ENABLE_FEATURE_NULL === null'); } @@ -328,31 +394,22 @@ pluginTester({ if (isTrue(runtimeFlags.ENABLE_FEATURE_TRUE)) { console.log('runtimeFlags.ENABLE_FEATURE_TRUE'); } - - if (!runtimeFlags.ENABLE_FEATURE_FALSE) { - console.log('!runtimeFlags.ENABLE_FEATURE_FALSE'); - } `, }, 'should not transform member expressions that are not runtime flag lookups': { code: ` + import { runtimeFlags } from '@lwc/features'; if (churroteria.ENABLE_FEATURE_TRUE) { console.log('churroteria.ENABLE_FEATURE_TRUE'); } `, output: ` + import { runtimeFlags } from '@lwc/features'; + if (churroteria.ENABLE_FEATURE_TRUE) { console.log('churroteria.ENABLE_FEATURE_TRUE'); } `, }, - 'should not transform runtime flags when used with a ternary operator': { - code: ` - console.log(runtimeFlags.ENABLE_FEATURE_NULL ? 'foo' : 'bar'); - `, - output: ` - console.log(runtimeFlags.ENABLE_FEATURE_NULL ? 'foo' : 'bar'); - `, - }, - }), + }, }); diff --git a/packages/@lwc/features/src/babel-plugin/index.js b/packages/@lwc/features/src/babel-plugin/index.js index f25ed5dd01..77266cde24 100644 --- a/packages/@lwc/features/src/babel-plugin/index.js +++ b/packages/@lwc/features/src/babel-plugin/index.js @@ -7,18 +7,10 @@ const defaultFeatureFlags = require('../../').default; const RUNTIME_FLAGS_IDENTIFIER = 'runtimeFlags'; - -function isRuntimeFlag(path, featureFlags) { - return ( - path.isMemberExpression() && - path.get('object').isIdentifier({ name: RUNTIME_FLAGS_IDENTIFIER }) && - path.get('property').isIdentifier() && - featureFlags[path.node.property.name] !== undefined - ); -} +const FEATURES_PACKAGE_NAME = '@lwc/features'; function validate(name, value) { - if (!/[A-Z_]+/.test(name)) { + if (!/^[A-Z_]+$/.test(name)) { throw new Error( `Invalid feature flag "${name}". Flag name must only be composed of uppercase letters and underscores.` ); @@ -28,6 +20,11 @@ function validate(name, value) { } } +function isBindingReference(path, scope) { + const binding = scope && scope.getBinding(path.node.name); + return !!(binding && binding.referencePaths.includes(path)); +} + module.exports = function({ types: t }) { return { name: 'babel-plugin-lwc-features', @@ -35,27 +32,21 @@ module.exports = function({ types: t }) { // `pre()` doesn't have access to the `this.opts` plugin options so // we initialize in the Program visitor instead. Program() { - this.featureFlagIfStatements = []; this.featureFlags = this.opts.featureFlags || defaultFeatureFlags; - this.importDeclarationScope = []; }, - ImportDefaultSpecifier(defaultSpecifierPath) { - const importDeclarationPath = defaultSpecifierPath.findParent(p => - p.isImportDeclaration() - ); - if (importDeclarationPath.node.source.value === '@lwc/features') { - this.importDeclarationScope = importDeclarationPath.scope; - this.defaultSpecifierName = defaultSpecifierPath.node.local.name; - const specifiers = importDeclarationPath.get('specifiers'); - const didImportRuntimeFlags = specifiers - .filter(specifier => specifier !== defaultSpecifierPath) - .some(specifier => { - return specifier.node.imported.name === RUNTIME_FLAGS_IDENTIFIER; - }); + ImportDeclaration(path) { + if (path.node.source.value === FEATURES_PACKAGE_NAME) { + this.importDeclarationPath = path; + const specifiers = path.node.specifiers; + + // Check if we've already imported runtime flags + const didImportRuntimeFlags = specifiers.some(specifier => { + return specifier.local && specifier.local.name === RUNTIME_FLAGS_IDENTIFIER; + }); if (!didImportRuntimeFlags) { - // Blindly import a binding for `runtimeFlags`. Tree-shaking - // will simply remove it if unused. - importDeclarationPath.node.specifiers.push( + // Blindly import a binding for `runtimeFlags` if we haven't + // already. Tree-shaking will simply remove it if unused. + specifiers.push( t.importSpecifier( t.identifier(RUNTIME_FLAGS_IDENTIFIER), t.identifier(RUNTIME_FLAGS_IDENTIFIER) @@ -64,48 +55,62 @@ module.exports = function({ types: t }) { } } }, + ImportDefaultSpecifier(path) { + // If this is the default specifier for the @lwc/features import declaration + if (path.parentPath === this.importDeclarationPath) { + this.defaultImportName = path.get('local.name').node; + } + }, IfStatement(path) { - const testPath = path.get('test'); + let testPath = path.get('test'); - // If we have imported the feature flags lookup (default binding) and the if-test is a member expression. - if (this.defaultSpecifierName && testPath.isMemberExpression()) { - const objectPath = testPath.get('object'); - const propertyPath = testPath.get('property'); - // If the member expression is a shallow feature flag lookup (i.e., the property is an identifier). - if ( - objectPath.isIdentifier({ name: this.defaultSpecifierName }) && - propertyPath.isIdentifier() - ) { - const binding = this.importDeclarationScope.getBinding( - objectPath.node.name - ); - // If this thing is an actual reference to the imported feature flag lookup. - if (binding && binding.referencePaths.includes(objectPath)) { - const name = propertyPath.node.name; - const value = this.featureFlags[name]; - validate(name, value); - if (!this.opts.prod || value === null) { - testPath.replaceWithSourceString( - `${RUNTIME_FLAGS_IDENTIFIER}.${name}` - ); - } else if (value === true) { - // Transform the IfStatement into a BlockStatement - path.replaceWith(path.node.consequent); - } else if (value === false) { - // Remove IfStatement - path.remove(); - } - } + const isUnaryNegation = testPath.isUnaryExpression({ operator: '!' }); + if (isUnaryNegation) { + testPath = testPath.get('argument'); + } + + if (!testPath.isMemberExpression()) { + return; + } + + const objectPath = testPath.get('object'); + const propertyPath = testPath.get('property'); + const isRuntimeFlag = objectPath.isIdentifier({ name: RUNTIME_FLAGS_IDENTIFIER }); + + let isCompileTimeFlag = false; + if (this.defaultImportName) { + isCompileTimeFlag = objectPath.isIdentifier({ + name: this.defaultImportName, + }); + } + + // If the member expression object is neither the imported default binding nor the runtimeFlags binding + if (!isRuntimeFlag && !isCompileTimeFlag) { + return; + } + // If the member expression object is not a binding reference to the feature flag object + if ( + this.importDeclarationPath && + !isBindingReference(objectPath, this.importDeclarationPath.scope) + ) { + return; + } + + const name = propertyPath.node.name; + let value = this.featureFlags[name]; + validate(name, value); + + if (!this.opts.prod || value === null) { + if (isCompileTimeFlag) { + testPath.node.object = t.identifier(RUNTIME_FLAGS_IDENTIFIER); + return; } } - // Transform runtime flags into compile-time flags, where appropriate, for - // production mode. This serves to undo the non-production mode transform of - // forcing all flags to be runtime flags. - if (this.opts.prod && isRuntimeFlag(testPath, this.featureFlags)) { - const name = testPath.node.property.name; - const value = this.featureFlags[name]; - validate(name, value); + if (this.opts.prod) { + if (isUnaryNegation) { + value = !value; + } if (value === true) { // Transform the IfStatement into a BlockStatement path.replaceWith(path.node.consequent);