Skip to content

Commit

Permalink
fix(ngcc): support the UMD wrapper function format emitted by Webpack
Browse files Browse the repository at this point in the history
Previously, ngcc could only handle UMD modules whose wrapper function
was implemented as a `ts.ConditionalExpression` (i.e. using a ternary
operator). This is the format emitted by popular bundlers, such as
Rollup.

This commit adds support for a different format, that uses `if/else`
statements, which is what is [emitted by Webpack][1].

[1]: https://webpack.js.org/configuration/output/#type-umd

Fixes angular#44019
  • Loading branch information
gkalpak committed Nov 21, 2021
1 parent 50d49e3 commit a874cab
Show file tree
Hide file tree
Showing 4 changed files with 2,830 additions and 2,787 deletions.
146 changes: 126 additions & 20 deletions packages/compiler-cli/ngcc/src/host/umd_host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {DefinePropertyReexportStatement, ExportDeclaration, ExportsStatement, ex
import {getInnerClassDeclaration, getOuterNodeFromInnerDeclaration, isAssignment} from './esm2015_host';
import {Esm5ReflectionHost} from './esm5_host';
import {NgccClassSymbol} from './ngcc_host';
import {stripParentheses} from './utils';
import {RequireAtLeastOneNonNullable, stripParentheses} from './utils';

export class UmdReflectionHost extends Esm5ReflectionHost {
protected umdModules =
Expand Down Expand Up @@ -553,10 +553,15 @@ function getUmdWrapper(statement: ts.Statement):

/**
* Parse the wrapper function of a UMD module and extract info about the factory function calls for
* the various formats (CommonJS, AMD, global).
* the various formats (CommonJS, CommonJS2, AMD, global).
*
* The supported format for the UMD wrapper function body is a single statement which is a
* `ts.ConditionalExpression` (i.e. using a ternary operator). For example:
* NOTE:
* For more info on the distinction between CommonJS and CommonJS2 see
* https://github.com/webpack/webpack/issues/1114.
*
* The supported format for the UMD wrapper function body is a single statement which is either a
* `ts.ConditionalExpression` (i.e. using a ternary operator) (typically emitted by Rollup) or a
* `ts.IfStatement` (typically emitted by Webpack). For example:
*
* ```js
* // Using a conditional expression:
Expand All @@ -573,32 +578,57 @@ function getUmdWrapper(statement: ts.Statement):
* // ...
* }));
* ```
*
* or
*
* ```js
* // Using an `if` statement:
* (function (root, factory) {
* if (typeof exports === 'object' && typeof module === 'object')
* // CommonJS2 factory call.
* module.exports = factory(require('foo'), require('bar'));
* else if (typeof define === 'function' && define.amd)
* // AMD factory call.
* define(['foo', 'bar'], factory);
* else if (typeof exports === 'object')
* // CommonJS factory call.
* exports['my-lib'] = factory(require('foo'), require('bar'));
* else
* // Global factory call.
* root['my-lib'] = factory(root['foo'], root['bar']);
* })(global, function (foo, bar) {
* // ...
* });
* ```
*/
function parseUmdWrapperFunction(wrapperFn: ts.FunctionExpression): UmdModule['factoryCalls'] {
const stmt = wrapperFn.body.statements[0];
let conditionalFactoryCalls: UmdConditionalFactoryCall[];

if (ts.isExpressionStatement(stmt) && ts.isConditionalExpression(stmt.expression)) {
conditionalFactoryCalls = extractFactoryCallsFromConditionalExpression(stmt.expression);
} else if (ts.isIfStatement(stmt)) {
conditionalFactoryCalls = extractFactoryCallsFromIfStatement(stmt);
} else {
throw new Error(
'UMD wrapper body is not in a supported format (expected a conditional expression):\n' +
wrapperFn.body.getText());
'UMD wrapper body is not in a supported format (expected a conditional expression or if ' +
'statement):\n' + wrapperFn.body.getText());
}

const factoryCalls = {
amdDefine: getAmdDefineCall(conditionalFactoryCalls),
commonJs: getCommonJsFactoryCall(conditionalFactoryCalls),
commonJs2: getCommonJs2FactoryCall(conditionalFactoryCalls),
global: getGlobalFactoryCall(conditionalFactoryCalls),
};

if (factoryCalls.commonJs === null) {
if (factoryCalls.commonJs === null && factoryCalls.commonJs2 === null) {
throw new Error(
'Unable to find a CommonJS factory call inside the UMD wrapper function:\n' +
'Unable to find a CommonJS or CommonJS2 factory call inside the UMD wrapper function:\n' +
stmt.getText());
}

return factoryCalls as (typeof factoryCalls&{commonJs: ts.CallExpression});
return factoryCalls as RequireAtLeastOneNonNullable<typeof factoryCalls, 'commonJs'|'commonJs2'>;
}

/**
Expand Down Expand Up @@ -643,6 +673,64 @@ function extractFactoryCallsFromConditionalExpression(node: ts.ConditionalExpres
return factoryCalls;
}

/**
* Extract `UmdConditionalFactoryCall`s from a `ts.IfStatement` of the form:
*
* ```js
* if (typeof exports === 'object' && typeof module === 'object')
* // CommonJS2 factory call.
* module.exports = factory(require('foo'), require('bar'));
* else if (typeof define === 'function' && define.amd)
* // AMD factory call.
* define(['foo', 'bar'], factory);
* else if (typeof exports === 'object')
* // CommonJS factory call.
* exports['my-lib'] = factory(require('foo'), require('bar'));
* else
* // Global factory call.
* root['my-lib'] = factory(root['foo'], root['bar']);
* ```
*/
function extractFactoryCallsFromIfStatement(node: ts.IfStatement): UmdConditionalFactoryCall[] {
const factoryCalls: UmdConditionalFactoryCall[] = [];
let currentNode: ts.Statement|undefined = node;

while (currentNode && ts.isIfStatement(currentNode)) {
if (!ts.isBinaryExpression(currentNode.expression)) {
throw new Error(
'Condition inside UMD wrapper is not a binary expression:\n' +
currentNode.expression.getText());
}
if (!ts.isExpressionStatement(currentNode.thenStatement)) {
throw new Error(
'Then-statement inside UMD wrapper is not an expression statement:\n' +
currentNode.thenStatement.getText());
}

factoryCalls.push({
condition: currentNode.expression,
factoryCall: getFunctionCallFromExpression(currentNode.thenStatement.expression),
});

currentNode = currentNode.elseStatement;
}

if (currentNode) {
if (!ts.isExpressionStatement(currentNode)) {
throw new Error(
'Else-statement inside UMD wrapper is not an expression statement:\n' +
currentNode.getText());
}

factoryCalls.push({
condition: null,
factoryCall: getFunctionCallFromExpression(currentNode.expression),
});
}

return factoryCalls;
}

function getFunctionCallFromExpression(node: ts.Expression): ts.CallExpression {
// Be resilient to `node` being inside parenthesis.
if (ts.isParenthesizedExpression(node)) {
Expand All @@ -651,8 +739,9 @@ function getFunctionCallFromExpression(node: ts.Expression): ts.CallExpression {
return getFunctionCallFromExpression(node.expression);
}

// Be resilient to `node` being part of a comma expression.
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.CommaToken) {
// Be resilient to `node` being part of an assignment or comma expression.
if (ts.isBinaryExpression(node) &&
[ts.SyntaxKind.CommaToken, ts.SyntaxKind.EqualsToken].includes(node.operatorToken.kind)) {
// NOTE:
// Since we are going further down the AST, there is no risk of infinite recursion.
return getFunctionCallFromExpression(node.right);
Expand Down Expand Up @@ -684,15 +773,29 @@ function getAmdDefineCall(calls: UmdConditionalFactoryCall[]): ts.CallExpression
* Get the factory call for setting up the CommonJS dependencies in the UMD wrapper.
*/
function getCommonJsFactoryCall(calls: UmdConditionalFactoryCall[]): ts.CallExpression|null {
// The factory call for CommonJS dependencies is the one that is guarded with a `&&` expression
// whose one side is a `typeof exports` or `typeof module` condition.
// The factory call for CommonJS dependencies is the one that is guarded with a `typeof exports`
// condition.
const cjsConditionalCall = calls.find(
call => call.condition?.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken &&
isTypeOf(call.condition, 'exports') && ts.isIdentifier(call.factoryCall.expression) &&
call.factoryCall.expression.text === 'factory');

return cjsConditionalCall?.factoryCall ?? null;
}

/**
* Get the factory call for setting up the CommonJS2 dependencies in the UMD wrapper.
*/
function getCommonJs2FactoryCall(calls: UmdConditionalFactoryCall[]): ts.CallExpression|null {
// The factory call for CommonJS2 dependencies is the one that is guarded with a `&&` expression
// whose one side is a `typeof exports` or `typeof module` condition.
const cjs2ConditionalCall = calls.find(
call => call.condition?.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken &&
oneOfBinaryConditions(call.condition, exp => isTypeOf(exp, 'exports', 'module')) &&
ts.isIdentifier(call.factoryCall.expression) &&
call.factoryCall.expression.text === 'factory');

return cjsConditionalCall?.factoryCall ?? null;
return cjs2ConditionalCall?.factoryCall ?? null;
}

/**
Expand All @@ -719,9 +822,13 @@ function isTypeOf(node: ts.Expression, ...types: string[]): boolean {
export function getImportsOfUmdModule(umdModule: UmdModule):
{parameter: ts.ParameterDeclaration, path: string}[] {
const imports: {parameter: ts.ParameterDeclaration, path: string}[] = [];
const cjsFactoryCall = umdModule.factoryCalls.commonJs;
const cjsFactoryCall = umdModule.factoryCalls.commonJs2 ?? umdModule.factoryCalls.commonJs!;

// Some UMD formats pass `exports` as the first argument to the factory call, while others don't.
// Compute the index at which the dependencies start (i.e. the index of the first `require` call).
const depStartIndex = cjsFactoryCall.arguments.findIndex(arg => isRequireCall(arg));

for (let i = 1; i < umdModule.factoryFn.parameters.length; i++) {
for (let i = depStartIndex; i < umdModule.factoryFn.parameters.length; i++) {
imports.push({
parameter: umdModule.factoryFn.parameters[i],
path: getRequiredModulePath(cjsFactoryCall, i),
Expand All @@ -734,10 +841,9 @@ export function getImportsOfUmdModule(umdModule: UmdModule):
interface UmdModule {
wrapperFn: ts.FunctionExpression;
factoryFn: ts.FunctionExpression;
factoryCalls: {
commonJs: ts.CallExpression; amdDefine: ts.CallExpression | null;
global: ts.CallExpression | null;
};
factoryCalls: RequireAtLeastOneNonNullable<
Record<'amdDefine'|'commonJs'|'commonJs2'|'global', ts.CallExpression|null>,
'commonJs'|'commonJs2'>;
}

/**
Expand Down
23 changes: 23 additions & 0 deletions packages/compiler-cli/ngcc/src/host/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@
*/
import ts from 'typescript';

/**
* Require that at least one of the specified properties of a type are not null/undefined.
*
* For example, given a type `T` of the form `Record<'foo' | 'bar' | 'baz', number | null>`, you can
* specify that at least one of the properties `foo` and `bar` will be a number with
* `RequireAtLeastOneNonNullable<T, 'foo' | 'bar'>`. This would be essentially equivalent with:
*
* ```ts
* {
* foo: number;
* bar: number | null;
* baz: number | null;
* } | {
* foo: number | null;
* bar: number;
* baz: number | null;
* }
* ```
*/
export type RequireAtLeastOneNonNullable<T, Props extends keyof T> = {
[P in Props]: {[K in P]: NonNullable<T[P]>}&Omit<T, P>;
}[Props];

export function stripParentheses(node: ts.Node): ts.Node {
return ts.isParenthesizedExpression(node) ? node.expression : node;
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export class UmdRenderingFormatter extends Esm5RenderingFormatter {

// We need to add new `require()` calls for each import in the CommonJS initializer
renderCommonJsDependencies(output, factoryCalls.commonJs, imports);
renderCommonJsDependencies(output, factoryCalls.commonJs2, imports);
renderAmdDependencies(output, factoryCalls.amdDefine, imports);
renderGlobalDependencies(output, factoryCalls.global, imports);
renderFactoryParameters(output, factoryFn, imports);
Expand Down Expand Up @@ -134,7 +135,7 @@ export class UmdRenderingFormatter extends Esm5RenderingFormatter {
}

/**
* Add dependencies to the CommonJS part of the UMD wrapper function.
* Add dependencies to the CommonJS/CommonJS2 part of the UMD wrapper function.
*/
function renderCommonJsDependencies(
output: MagicString, factoryCall: ts.CallExpression|null, imports: Import[]) {
Expand Down

0 comments on commit a874cab

Please sign in to comment.