Skip to content

Commit

Permalink
fix(bundling): allow proper webpack treeshaking (ionic-team#3248)
Browse files Browse the repository at this point in the history
this commit solves an issue where stencil projects using webpack were
unable to treeshake properly. specifically, this is a workaround to a
webpack issue (webpack/webpack#14963) where
webpack fails to treeshake when a variable is reassigned.

with this commit, we introduce a new transformer to be run during the
typescript transpilation process, `proxyCustomElement` that takes a
stencil component's class initializer and hoists it as the first
argument of `proxyCustomElement`. this eliminates the need to reassign
the variable in the final output, which was causing code generated using
the `dist-custom-elements` output target to fail to treeshake when used
in a webpack project.

with the introduction of this separate transformer, the creation of the
`proxyCustomElement` call is removed from the
`addDefineCustomElementFunctions` transformer. this was done for two
reasons:
1. separation of concerns - proxying the component is not strictly
   necessary when creating `define` calls
2. proxying must occur after the initializer has been generated.
   currently, this occurs in `nativeComponentTransform`. therefore, this
   step must occur after `nativeComponentTransform`.

as a part of creating this new transformer, a new function,
`createAnonymousClassMetadataProxy` was created. we intentionally choose
not to use the existing proxy creation funcitons in the same file where
the new file is defiend in order to pass the class initializer directly
to our new helper function.

update-component-class has a variable statement creation call that was
modified from `const` to `let` in
6987e43. With this commit, we can
safely revert this change as we no longer redefine the variable holding
the stencil component
  • Loading branch information
rwaskiewicz authored Feb 23, 2022
1 parent eebf68b commit 5dccc85
Show file tree
Hide file tree
Showing 7 changed files with 417 additions and 18 deletions.
2 changes: 2 additions & 0 deletions src/compiler/output-targets/dist-custom-elements/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { addDefineCustomElementFunctions } from '../../transformers/component-na
import { optimizeModule } from '../../optimize/optimize-module';
import { removeCollectionImports } from '../../transformers/remove-collection-imports';
import { STENCIL_INTERNAL_CLIENT_ID, USER_INDEX_ENTRY_ID, STENCIL_APP_GLOBALS_ID } from '../../bundle/entry-alias-ids';
import { proxyCustomElement } from '../../transformers/component-native/proxy-custom-element-function';
import { updateStencilCoreImports } from '../../transformers/update-stencil-core-import';

export const outputCustomElements = async (
Expand Down Expand Up @@ -186,6 +187,7 @@ const getCustomElementBundleCustomTransformer = (
addDefineCustomElementFunctions(compilerCtx, components, outputTarget),
updateStencilCoreImports(transformOpts.coreImportPath),
nativeComponentTransform(compilerCtx, transformOpts),
proxyCustomElement(compilerCtx, transformOpts),
removeCollectionImports(compilerCtx),
];
};
26 changes: 26 additions & 0 deletions src/compiler/transformers/add-component-meta-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,29 @@ export const createComponentMetadataProxy = (compilerMeta: d.ComponentCompilerMe

return ts.createCall(ts.createIdentifier(PROXY_CUSTOM_ELEMENT), [], [literalCmpClassName, literalMeta]);
};

/**
* Create a call expression for wrapping a component represented as an anonymous class in a proxy. This call expression
* takes a form:
* ```ts
* PROXY_CUSTOM_ELEMENT(Clazz, Metadata);
* ```
* where
* - `PROXY_CUSTOM_ELEMENT` is a Stencil internal identifier that will be replaced with the name of the actual function
* name at compile name
* - `Clazz` is an anonymous class to be proxied
* - `Metadata` is the compiler metadata associated with the Stencil component
*
* @param compilerMeta compiler metadata associated with the component to be wrapped in a proxy
* @param clazz the anonymous class to proxy
* @returns the generated call expression
*/
export const createAnonymousClassMetadataProxy = (
compilerMeta: d.ComponentCompilerMeta,
clazz: ts.Expression
): ts.CallExpression => {
const compactMeta: d.ComponentRuntimeMetaCompact = formatComponentRuntimeMeta(compilerMeta, true);
const literalMeta = convertValueToLiteral(compactMeta);

return ts.factory.createCallExpression(ts.factory.createIdentifier(PROXY_CUSTOM_ELEMENT), [], [clazz, literalMeta]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import type * as d from '../../../declarations';
import { createImportStatement, getModuleFromSourceFile } from '../transform-utils';
import { dashToPascalCase } from '@utils';
import ts from 'typescript';
import { createComponentMetadataProxy } from '../add-component-meta-proxy';
import { addCoreRuntimeApi, RUNTIME_APIS } from '../core-runtime-apis';

/**
* Import and define components along with any component dependents within the `dist-custom-elements` output.
Expand All @@ -25,25 +23,10 @@ export const addDefineCustomElementFunctions = (
const caseStatements: ts.CaseClause[] = [];
const tagNames: string[] = [];

addCoreRuntimeApi(moduleFile, RUNTIME_APIS.proxyCustomElement);

if (moduleFile.cmps.length) {
const principalComponent = moduleFile.cmps[0];
tagNames.push(principalComponent.tagName);

// wraps the initial component class in a `proxyCustomElement` wrapper.
// This is what will be exported and called from the `defineCustomElement` call.
const proxyDefinition = createComponentMetadataProxy(principalComponent);
const metaExpression = ts.factory.createExpressionStatement(
ts.factory.createBinaryExpression(
ts.factory.createIdentifier(principalComponent.componentClassName),
ts.factory.createToken(ts.SyntaxKind.EqualsToken),
proxyDefinition
)
);
newStatements.push(metaExpression);
ts.addSyntheticLeadingComment(proxyDefinition, ts.SyntaxKind.MultiLineCommentTrivia, '@__PURE__', false);

// define the current component - `customElements.define(tagName, MyProxiedComponent);`
const customElementsDefineCallExpression = ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier('customElements'), 'define'),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import ts from 'typescript';
import type * as d from '../../../declarations';
import { createAnonymousClassMetadataProxy } from '../add-component-meta-proxy';
import { addImports } from '../add-imports';
import { RUNTIME_APIS } from '../core-runtime-apis';
import { getModuleFromSourceFile } from '../transform-utils';

/**
* Proxy custom elements for the `dist-custom-elements` output target. This function searches for a Stencil component's
* class initializer (found on the righthand side of the '=' operator):
*
* ```ts
* const MyComponent = class extends HTMLElement { // Implementation omitted }
* ```
*
* and wraps the initializer into a `proxyCustomElement` call:
*
* ```ts
* const MyComponent = proxyCustomElement(class extends HTMLElement { // Implementation omitted }, componentMetadata);
* ```
*
* This is to work around an issue where treeshaking does not work for webpack users, whose details are captured in full
* in [this issue on the webpack GitHub repo](https://github.com/webpack/webpack/issues/14963).
*
* @param compilerCtx current compiler context
* @param transformOpts transpilation options for the current build
* @returns a TypeScript AST transformer factory function that performs the above described transformation
*/
export const proxyCustomElement = (
compilerCtx: d.CompilerCtx,
transformOpts: d.TransformOptions
): ts.TransformerFactory<ts.SourceFile> => {
return () => {
return (tsSourceFile: ts.SourceFile): ts.SourceFile => {
const moduleFile = getModuleFromSourceFile(compilerCtx, tsSourceFile);
if (!moduleFile.cmps.length) {
return tsSourceFile;
}

const principalComponent = moduleFile.cmps[0];

for (let [stmtIndex, stmt] of tsSourceFile.statements.entries()) {
if (ts.isVariableStatement(stmt)) {
for (let [declarationIndex, declaration] of stmt.declarationList.declarations.entries()) {
if (declaration.name.getText() !== principalComponent.componentClassName) {
continue;
}

// wrap the Stencil component's class declaration in a component proxy
const proxyCreationCall = createAnonymousClassMetadataProxy(principalComponent, declaration.initializer);
ts.addSyntheticLeadingComment(proxyCreationCall, ts.SyntaxKind.MultiLineCommentTrivia, '@__PURE__', false);

// update the component's variable declaration to use the new initializer
const proxiedComponentDeclaration = ts.factory.updateVariableDeclaration(
declaration,
declaration.name,
declaration.exclamationToken,
declaration.type,
proxyCreationCall
);

// update the declaration list that contains the updated variable declaration
const updatedDeclarationList = ts.factory.updateVariableDeclarationList(stmt.declarationList, [
...stmt.declarationList.declarations.slice(0, declarationIndex),
proxiedComponentDeclaration,
...stmt.declarationList.declarations.slice(declarationIndex + 1),
]);

// update the variable statement containing the updated declaration list
const updatedVariableStatement = ts.factory.updateVariableStatement(
stmt,
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
updatedDeclarationList
);

// update the source file's statements to use the new variable statement
tsSourceFile = ts.factory.updateSourceFile(tsSourceFile, [
...tsSourceFile.statements.slice(0, stmtIndex),
updatedVariableStatement,
...tsSourceFile.statements.slice(stmtIndex + 1),
]);

// finally, ensure that the proxyCustomElement function is imported
tsSourceFile = addImports(
transformOpts,
tsSourceFile,
[RUNTIME_APIS.proxyCustomElement],
transformOpts.coreImportPath
);

return tsSourceFile;
}
}
}
return tsSourceFile;
};
};
};
109 changes: 109 additions & 0 deletions src/compiler/transformers/test/add-component-meta-proxy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type * as d from '../../../declarations';
import { createAnonymousClassMetadataProxy } from '../add-component-meta-proxy';
import * as TransformUtils from '../transform-utils';
import * as FormatComponentRuntimeMeta from '../../../utils/format-component-runtime-meta';
import ts from 'typescript';
import { HTML_ELEMENT } from '../core-runtime-apis';

describe('add-component-meta-proxy', () => {
describe('createAnonymousClassMetadataProxy()', () => {
let classExpr: ts.ClassExpression;
let htmlElementHeritageClause: ts.HeritageClause;
let literalMetadata: ts.StringLiteral;

let formatComponentRuntimeMetaSpy: jest.SpyInstance<
ReturnType<typeof FormatComponentRuntimeMeta.formatComponentRuntimeMeta>,
Parameters<typeof FormatComponentRuntimeMeta.formatComponentRuntimeMeta>
>;
let convertValueToLiteralSpy: jest.SpyInstance<
ReturnType<typeof TransformUtils.convertValueToLiteral>,
Parameters<typeof TransformUtils.convertValueToLiteral>
>;

beforeEach(() => {
htmlElementHeritageClause = ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [
ts.factory.createExpressionWithTypeArguments(ts.factory.createIdentifier(HTML_ELEMENT), []),
]);

classExpr = ts.factory.createClassExpression(
undefined,
undefined,
'MyComponent',
undefined,
[htmlElementHeritageClause],
undefined
);
literalMetadata = ts.factory.createStringLiteral('MyComponent');

formatComponentRuntimeMetaSpy = jest.spyOn(FormatComponentRuntimeMeta, 'formatComponentRuntimeMeta');
formatComponentRuntimeMetaSpy.mockImplementation(
(_compilerMeta: d.ComponentCompilerMeta, _includeMethods: boolean) => [0, 'tag-name']
);

convertValueToLiteralSpy = jest.spyOn(TransformUtils, 'convertValueToLiteral');
convertValueToLiteralSpy.mockImplementation((_compactMeta: d.ComponentRuntimeMetaCompact) => literalMetadata);
});

afterEach(() => {
formatComponentRuntimeMetaSpy.mockRestore();
convertValueToLiteralSpy.mockRestore();
});

it('returns a call expression', () => {
const result: ts.CallExpression = createAnonymousClassMetadataProxy(
// TODO(STENCIL-378): Replace with a getMockComponentCompilerMeta() call
[] as unknown as d.ComponentCompilerMeta,
classExpr
);

expect(ts.isCallExpression(result)).toBe(true);
});

it('wraps the initializer in PROXY_CUSTOM_ELEMENT', () => {
const result: ts.CallExpression = createAnonymousClassMetadataProxy(
// TODO(STENCIL-378): Replace with a getMockComponentCompilerMeta() call
[] as unknown as d.ComponentCompilerMeta,
classExpr
);

expect((result.expression as ts.Identifier).escapedText).toBe('___stencil_proxyCustomElement');
});

it("doesn't add any type arguments to the call", () => {
const result: ts.CallExpression = createAnonymousClassMetadataProxy(
// TODO(STENCIL-378): Replace with a getMockComponentCompilerMeta() call
[] as unknown as d.ComponentCompilerMeta,
classExpr
);

expect(result.typeArguments).toHaveLength(0);
});

it('adds the correct arguments to the PROXY_CUSTOM_ELEMENT call', () => {
const result: ts.CallExpression = createAnonymousClassMetadataProxy(
// TODO(STENCIL-378): Replace with a getMockComponentCompilerMeta() call
[] as unknown as d.ComponentCompilerMeta,
classExpr
);

expect(result.arguments).toHaveLength(2);
expect(result.arguments[0]).toBe(classExpr);
expect(result.arguments[1]).toBe(literalMetadata);
});

it('includes the heritage clause', () => {
const result: ts.CallExpression = createAnonymousClassMetadataProxy(
// TODO(STENCIL-378): Replace with a getMockComponentCompilerMeta() call
[] as unknown as d.ComponentCompilerMeta,
classExpr
);

expect(result.arguments.length).toBeGreaterThanOrEqual(1);
const createdClassExpression = result.arguments[0];

expect(ts.isClassExpression(createdClassExpression)).toBe(true);
expect((createdClassExpression as ts.ClassExpression).heritageClauses).toHaveLength(1);
expect((createdClassExpression as ts.ClassExpression).heritageClauses[0]).toBe(htmlElementHeritageClause);
});
});
});
Loading

0 comments on commit 5dccc85

Please sign in to comment.