Skip to content

Commit 5dccc85

Browse files
authored
fix(bundling): allow proper webpack treeshaking (#3248)
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
1 parent eebf68b commit 5dccc85

7 files changed

Lines changed: 417 additions & 18 deletions

File tree

src/compiler/output-targets/dist-custom-elements/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { addDefineCustomElementFunctions } from '../../transformers/component-na
1717
import { optimizeModule } from '../../optimize/optimize-module';
1818
import { removeCollectionImports } from '../../transformers/remove-collection-imports';
1919
import { STENCIL_INTERNAL_CLIENT_ID, USER_INDEX_ENTRY_ID, STENCIL_APP_GLOBALS_ID } from '../../bundle/entry-alias-ids';
20+
import { proxyCustomElement } from '../../transformers/component-native/proxy-custom-element-function';
2021
import { updateStencilCoreImports } from '../../transformers/update-stencil-core-import';
2122

2223
export const outputCustomElements = async (
@@ -186,6 +187,7 @@ const getCustomElementBundleCustomTransformer = (
186187
addDefineCustomElementFunctions(compilerCtx, components, outputTarget),
187188
updateStencilCoreImports(transformOpts.coreImportPath),
188189
nativeComponentTransform(compilerCtx, transformOpts),
190+
proxyCustomElement(compilerCtx, transformOpts),
189191
removeCollectionImports(compilerCtx),
190192
];
191193
};

src/compiler/transformers/add-component-meta-proxy.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,29 @@ export const createComponentMetadataProxy = (compilerMeta: d.ComponentCompilerMe
2626

2727
return ts.createCall(ts.createIdentifier(PROXY_CUSTOM_ELEMENT), [], [literalCmpClassName, literalMeta]);
2828
};
29+
30+
/**
31+
* Create a call expression for wrapping a component represented as an anonymous class in a proxy. This call expression
32+
* takes a form:
33+
* ```ts
34+
* PROXY_CUSTOM_ELEMENT(Clazz, Metadata);
35+
* ```
36+
* where
37+
* - `PROXY_CUSTOM_ELEMENT` is a Stencil internal identifier that will be replaced with the name of the actual function
38+
* name at compile name
39+
* - `Clazz` is an anonymous class to be proxied
40+
* - `Metadata` is the compiler metadata associated with the Stencil component
41+
*
42+
* @param compilerMeta compiler metadata associated with the component to be wrapped in a proxy
43+
* @param clazz the anonymous class to proxy
44+
* @returns the generated call expression
45+
*/
46+
export const createAnonymousClassMetadataProxy = (
47+
compilerMeta: d.ComponentCompilerMeta,
48+
clazz: ts.Expression
49+
): ts.CallExpression => {
50+
const compactMeta: d.ComponentRuntimeMetaCompact = formatComponentRuntimeMeta(compilerMeta, true);
51+
const literalMeta = convertValueToLiteral(compactMeta);
52+
53+
return ts.factory.createCallExpression(ts.factory.createIdentifier(PROXY_CUSTOM_ELEMENT), [], [clazz, literalMeta]);
54+
};

src/compiler/transformers/component-native/add-define-custom-element-function.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import type * as d from '../../../declarations';
22
import { createImportStatement, getModuleFromSourceFile } from '../transform-utils';
33
import { dashToPascalCase } from '@utils';
44
import ts from 'typescript';
5-
import { createComponentMetadataProxy } from '../add-component-meta-proxy';
6-
import { addCoreRuntimeApi, RUNTIME_APIS } from '../core-runtime-apis';
75

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

28-
addCoreRuntimeApi(moduleFile, RUNTIME_APIS.proxyCustomElement);
29-
3026
if (moduleFile.cmps.length) {
3127
const principalComponent = moduleFile.cmps[0];
3228
tagNames.push(principalComponent.tagName);
3329

34-
// wraps the initial component class in a `proxyCustomElement` wrapper.
35-
// This is what will be exported and called from the `defineCustomElement` call.
36-
const proxyDefinition = createComponentMetadataProxy(principalComponent);
37-
const metaExpression = ts.factory.createExpressionStatement(
38-
ts.factory.createBinaryExpression(
39-
ts.factory.createIdentifier(principalComponent.componentClassName),
40-
ts.factory.createToken(ts.SyntaxKind.EqualsToken),
41-
proxyDefinition
42-
)
43-
);
44-
newStatements.push(metaExpression);
45-
ts.addSyntheticLeadingComment(proxyDefinition, ts.SyntaxKind.MultiLineCommentTrivia, '@__PURE__', false);
46-
4730
// define the current component - `customElements.define(tagName, MyProxiedComponent);`
4831
const customElementsDefineCallExpression = ts.factory.createCallExpression(
4932
ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier('customElements'), 'define'),
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import ts from 'typescript';
2+
import type * as d from '../../../declarations';
3+
import { createAnonymousClassMetadataProxy } from '../add-component-meta-proxy';
4+
import { addImports } from '../add-imports';
5+
import { RUNTIME_APIS } from '../core-runtime-apis';
6+
import { getModuleFromSourceFile } from '../transform-utils';
7+
8+
/**
9+
* Proxy custom elements for the `dist-custom-elements` output target. This function searches for a Stencil component's
10+
* class initializer (found on the righthand side of the '=' operator):
11+
*
12+
* ```ts
13+
* const MyComponent = class extends HTMLElement { // Implementation omitted }
14+
* ```
15+
*
16+
* and wraps the initializer into a `proxyCustomElement` call:
17+
*
18+
* ```ts
19+
* const MyComponent = proxyCustomElement(class extends HTMLElement { // Implementation omitted }, componentMetadata);
20+
* ```
21+
*
22+
* This is to work around an issue where treeshaking does not work for webpack users, whose details are captured in full
23+
* in [this issue on the webpack GitHub repo](https://github.com/webpack/webpack/issues/14963).
24+
*
25+
* @param compilerCtx current compiler context
26+
* @param transformOpts transpilation options for the current build
27+
* @returns a TypeScript AST transformer factory function that performs the above described transformation
28+
*/
29+
export const proxyCustomElement = (
30+
compilerCtx: d.CompilerCtx,
31+
transformOpts: d.TransformOptions
32+
): ts.TransformerFactory<ts.SourceFile> => {
33+
return () => {
34+
return (tsSourceFile: ts.SourceFile): ts.SourceFile => {
35+
const moduleFile = getModuleFromSourceFile(compilerCtx, tsSourceFile);
36+
if (!moduleFile.cmps.length) {
37+
return tsSourceFile;
38+
}
39+
40+
const principalComponent = moduleFile.cmps[0];
41+
42+
for (let [stmtIndex, stmt] of tsSourceFile.statements.entries()) {
43+
if (ts.isVariableStatement(stmt)) {
44+
for (let [declarationIndex, declaration] of stmt.declarationList.declarations.entries()) {
45+
if (declaration.name.getText() !== principalComponent.componentClassName) {
46+
continue;
47+
}
48+
49+
// wrap the Stencil component's class declaration in a component proxy
50+
const proxyCreationCall = createAnonymousClassMetadataProxy(principalComponent, declaration.initializer);
51+
ts.addSyntheticLeadingComment(proxyCreationCall, ts.SyntaxKind.MultiLineCommentTrivia, '@__PURE__', false);
52+
53+
// update the component's variable declaration to use the new initializer
54+
const proxiedComponentDeclaration = ts.factory.updateVariableDeclaration(
55+
declaration,
56+
declaration.name,
57+
declaration.exclamationToken,
58+
declaration.type,
59+
proxyCreationCall
60+
);
61+
62+
// update the declaration list that contains the updated variable declaration
63+
const updatedDeclarationList = ts.factory.updateVariableDeclarationList(stmt.declarationList, [
64+
...stmt.declarationList.declarations.slice(0, declarationIndex),
65+
proxiedComponentDeclaration,
66+
...stmt.declarationList.declarations.slice(declarationIndex + 1),
67+
]);
68+
69+
// update the variable statement containing the updated declaration list
70+
const updatedVariableStatement = ts.factory.updateVariableStatement(
71+
stmt,
72+
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
73+
updatedDeclarationList
74+
);
75+
76+
// update the source file's statements to use the new variable statement
77+
tsSourceFile = ts.factory.updateSourceFile(tsSourceFile, [
78+
...tsSourceFile.statements.slice(0, stmtIndex),
79+
updatedVariableStatement,
80+
...tsSourceFile.statements.slice(stmtIndex + 1),
81+
]);
82+
83+
// finally, ensure that the proxyCustomElement function is imported
84+
tsSourceFile = addImports(
85+
transformOpts,
86+
tsSourceFile,
87+
[RUNTIME_APIS.proxyCustomElement],
88+
transformOpts.coreImportPath
89+
);
90+
91+
return tsSourceFile;
92+
}
93+
}
94+
}
95+
return tsSourceFile;
96+
};
97+
};
98+
};
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type * as d from '../../../declarations';
2+
import { createAnonymousClassMetadataProxy } from '../add-component-meta-proxy';
3+
import * as TransformUtils from '../transform-utils';
4+
import * as FormatComponentRuntimeMeta from '../../../utils/format-component-runtime-meta';
5+
import ts from 'typescript';
6+
import { HTML_ELEMENT } from '../core-runtime-apis';
7+
8+
describe('add-component-meta-proxy', () => {
9+
describe('createAnonymousClassMetadataProxy()', () => {
10+
let classExpr: ts.ClassExpression;
11+
let htmlElementHeritageClause: ts.HeritageClause;
12+
let literalMetadata: ts.StringLiteral;
13+
14+
let formatComponentRuntimeMetaSpy: jest.SpyInstance<
15+
ReturnType<typeof FormatComponentRuntimeMeta.formatComponentRuntimeMeta>,
16+
Parameters<typeof FormatComponentRuntimeMeta.formatComponentRuntimeMeta>
17+
>;
18+
let convertValueToLiteralSpy: jest.SpyInstance<
19+
ReturnType<typeof TransformUtils.convertValueToLiteral>,
20+
Parameters<typeof TransformUtils.convertValueToLiteral>
21+
>;
22+
23+
beforeEach(() => {
24+
htmlElementHeritageClause = ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [
25+
ts.factory.createExpressionWithTypeArguments(ts.factory.createIdentifier(HTML_ELEMENT), []),
26+
]);
27+
28+
classExpr = ts.factory.createClassExpression(
29+
undefined,
30+
undefined,
31+
'MyComponent',
32+
undefined,
33+
[htmlElementHeritageClause],
34+
undefined
35+
);
36+
literalMetadata = ts.factory.createStringLiteral('MyComponent');
37+
38+
formatComponentRuntimeMetaSpy = jest.spyOn(FormatComponentRuntimeMeta, 'formatComponentRuntimeMeta');
39+
formatComponentRuntimeMetaSpy.mockImplementation(
40+
(_compilerMeta: d.ComponentCompilerMeta, _includeMethods: boolean) => [0, 'tag-name']
41+
);
42+
43+
convertValueToLiteralSpy = jest.spyOn(TransformUtils, 'convertValueToLiteral');
44+
convertValueToLiteralSpy.mockImplementation((_compactMeta: d.ComponentRuntimeMetaCompact) => literalMetadata);
45+
});
46+
47+
afterEach(() => {
48+
formatComponentRuntimeMetaSpy.mockRestore();
49+
convertValueToLiteralSpy.mockRestore();
50+
});
51+
52+
it('returns a call expression', () => {
53+
const result: ts.CallExpression = createAnonymousClassMetadataProxy(
54+
// TODO(STENCIL-378): Replace with a getMockComponentCompilerMeta() call
55+
[] as unknown as d.ComponentCompilerMeta,
56+
classExpr
57+
);
58+
59+
expect(ts.isCallExpression(result)).toBe(true);
60+
});
61+
62+
it('wraps the initializer in PROXY_CUSTOM_ELEMENT', () => {
63+
const result: ts.CallExpression = createAnonymousClassMetadataProxy(
64+
// TODO(STENCIL-378): Replace with a getMockComponentCompilerMeta() call
65+
[] as unknown as d.ComponentCompilerMeta,
66+
classExpr
67+
);
68+
69+
expect((result.expression as ts.Identifier).escapedText).toBe('___stencil_proxyCustomElement');
70+
});
71+
72+
it("doesn't add any type arguments to the call", () => {
73+
const result: ts.CallExpression = createAnonymousClassMetadataProxy(
74+
// TODO(STENCIL-378): Replace with a getMockComponentCompilerMeta() call
75+
[] as unknown as d.ComponentCompilerMeta,
76+
classExpr
77+
);
78+
79+
expect(result.typeArguments).toHaveLength(0);
80+
});
81+
82+
it('adds the correct arguments to the PROXY_CUSTOM_ELEMENT call', () => {
83+
const result: ts.CallExpression = createAnonymousClassMetadataProxy(
84+
// TODO(STENCIL-378): Replace with a getMockComponentCompilerMeta() call
85+
[] as unknown as d.ComponentCompilerMeta,
86+
classExpr
87+
);
88+
89+
expect(result.arguments).toHaveLength(2);
90+
expect(result.arguments[0]).toBe(classExpr);
91+
expect(result.arguments[1]).toBe(literalMetadata);
92+
});
93+
94+
it('includes the heritage clause', () => {
95+
const result: ts.CallExpression = createAnonymousClassMetadataProxy(
96+
// TODO(STENCIL-378): Replace with a getMockComponentCompilerMeta() call
97+
[] as unknown as d.ComponentCompilerMeta,
98+
classExpr
99+
);
100+
101+
expect(result.arguments.length).toBeGreaterThanOrEqual(1);
102+
const createdClassExpression = result.arguments[0];
103+
104+
expect(ts.isClassExpression(createdClassExpression)).toBe(true);
105+
expect((createdClassExpression as ts.ClassExpression).heritageClauses).toHaveLength(1);
106+
expect((createdClassExpression as ts.ClassExpression).heritageClauses[0]).toBe(htmlElementHeritageClause);
107+
});
108+
});
109+
});

0 commit comments

Comments
 (0)