Skip to content

Commit 4c8d8c0

Browse files
authored
fix(bundler): prevent vite bundling errors in downstream projects (#3349)
this commit adds experimental support for using stencil component libraries in projects that use bundlers such as vite. prior to this commit, stencil component libraries that were used in projects that used such bundlers would have issues lazily-loading components at runtime. this is due to restrictions the bundlers themselves place on the filepaths that can be used in dynamic import statements. this commit does not introduce the ability for stencil's compiler to use bundlers other than rollup under the hood. it only permits a compiled component library (that uses the `dist` output target) to be used in an application that uses a bundler built atop of rollup. due to the restrictions that rollup may impose on dynamic imports, this commit adds the ability to add an explicit `import()` statement for each lazily-loadable bundle. in order to keep the runtime small, this feature is hidden behind a new feature flag, `experimentalImportInjection` this pr build's atop the work done by @johnjenkins in #2959 and the test cases provided by @PrinceManfred in #2959 (comment). Without their contributions, this commit would not have been possible. add a stencil component library to be used in tests that verify applications that consume the library and are bundled with vite, parcel, etc. add an application that is built using vite to the bundler test directory. it consumes a small stencil library build using the `dist` output target, and verifies that the application can load the web component when the application has been built using vite. add infrastructure for running the bundler tests in karma. karma was chosen to align with existing parts of our technical stack (see the `test/karma` directory), and to expedite the initial implementation phase of these tests. karma can be difficult to configure, and even more difficult to add new (i.e. different) testing paradigms and testing strategies to. given that these tests do not use browserstack and are a significant departure from the existing karma tests, it felt 'ok' to split these off into a separate set of tests (with their own configuration). in order to get tests up and running, a utilities file, `test/bundler/karma-stencil-utils.ts` has been created. this file is largely based off of `test/karma/test-app/util.ts`. parts of the existing utility file were not ported over if they were deemed unnecessary, and attempts were made to clean up the existing code to improve their readability. wire the bundler tests to github actions. these tests are kept in a new reusable workflow that can run in parallel with existing analysis, unit and e2e tests STENCIL-339: Integrate Bundler Functionality
1 parent d134830 commit 4c8d8c0

32 files changed

+8592
-33
lines changed

.github/workflows/main.yml

+5
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ jobs:
2727
needs: [ build_core ]
2828
uses: ./.github/workflows/test-analysis.yml
2929

30+
bundler_tests:
31+
name: Bundler Tests
32+
needs: [ build_core ]
33+
uses: ./.github/workflows/test-bundlers.yml
34+
3035
e2e_tests:
3136
name: E2E Tests
3237
needs: [ build_core ]

.github/workflows/test-bundlers.yml

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Bundler Tests
2+
3+
on:
4+
workflow_call:
5+
# Make this a reusable workflow, no value needed
6+
# https://docs.github.com/en/actions/using-workflows/reusing-workflows
7+
8+
jobs:
9+
bundler_tests:
10+
name: Verify Bundlers
11+
runs-on: 'ubuntu-latest'
12+
steps:
13+
- name: Checkout Code
14+
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # v2.4.0
15+
16+
- name: Get Core Dependencies
17+
uses: ./.github/workflows/actions/get-core-dependencies
18+
19+
- name: Download Build Archive
20+
uses: ./.github/workflows/actions/download-archive
21+
with:
22+
name: stencil-core
23+
path: .
24+
filename: stencil-core-build.zip
25+
26+
- name: Bundler Tests
27+
run: npm run test.bundlers
28+
shell: bash

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"prettier.dry-run": "npm run prettier.base -- --list-different",
4646
"test": "jest --coverage",
4747
"test.analysis": "cd test && npm run analysis.build-and-analyze",
48+
"test.bundlers": "cd test && npm run bundlers",
4849
"test.dist": "node scripts --validate-build",
4950
"test.end-to-end": "cd test/end-to-end && npm ci && npm test && npm run test.dist",
5051
"test.jest": "jest",

src/client/client-load-module.ts

+2
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ export const loadModule = (
2222
if (module) {
2323
return module[exportName];
2424
}
25+
/*!__STENCIL_STATIC_IMPORT_SWITCH__*/
2526
return import(
27+
/* @vite-ignore */
2628
/* webpackInclude: /\.entry\.js$/ */
2729
/* webpackExclude: /\.system\.entry\.js$/ */
2830
/* webpackMode: "lazy" */

src/compiler/output-targets/dist-lazy/generate-lazy-module.ts

+114-33
Original file line numberDiff line numberDiff line change
@@ -30,39 +30,42 @@ export const generateLazyModules = async (
3030
const entryComponentsResults = rollupResults.filter((rollupResult) => rollupResult.isComponent);
3131
const chunkResults = rollupResults.filter((rollupResult) => !rollupResult.isComponent && !rollupResult.isEntry);
3232

33-
const [bundleModules] = await Promise.all([
34-
Promise.all(
35-
entryComponentsResults.map((rollupResult) => {
36-
return generateLazyEntryModule(
37-
config,
38-
compilerCtx,
39-
buildCtx,
40-
rollupResult,
41-
outputTargetType,
42-
destinations,
43-
sourceTarget,
44-
shouldMinify,
45-
isBrowserBuild,
46-
sufix
47-
);
48-
})
49-
),
50-
Promise.all(
51-
chunkResults.map((rollupResult) => {
52-
return writeLazyChunk(
53-
config,
54-
compilerCtx,
55-
buildCtx,
56-
rollupResult,
57-
outputTargetType,
58-
destinations,
59-
sourceTarget,
60-
shouldMinify,
61-
isBrowserBuild
62-
);
63-
})
64-
),
65-
]);
33+
const bundleModules = await Promise.all(
34+
entryComponentsResults.map((rollupResult) => {
35+
return generateLazyEntryModule(
36+
config,
37+
compilerCtx,
38+
buildCtx,
39+
rollupResult,
40+
outputTargetType,
41+
destinations,
42+
sourceTarget,
43+
shouldMinify,
44+
isBrowserBuild,
45+
sufix
46+
);
47+
})
48+
);
49+
50+
if (!!config.extras?.experimentalImportInjection && !isBrowserBuild) {
51+
addStaticImports(rollupResults, bundleModules);
52+
}
53+
54+
await Promise.all(
55+
chunkResults.map((rollupResult) => {
56+
return writeLazyChunk(
57+
config,
58+
compilerCtx,
59+
buildCtx,
60+
rollupResult,
61+
outputTargetType,
62+
destinations,
63+
sourceTarget,
64+
shouldMinify,
65+
isBrowserBuild
66+
);
67+
})
68+
);
6669

6770
const lazyRuntimeData = formatLazyBundlesRuntimeMeta(bundleModules);
6871
const entryResults = rollupResults.filter((rollupResult) => !rollupResult.isComponent && rollupResult.isEntry);
@@ -98,6 +101,84 @@ export const generateLazyModules = async (
98101
return bundleModules;
99102
};
100103

104+
/**
105+
* Add imports for each bundle to Stencil's lazy loader. Some bundlers that are built atop of Rollup strictly impose
106+
* the limitations that are laid out in https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations.
107+
* This function injects an explicit import statement for each bundle that can be lazily loaded.
108+
* @param rollupChunkResults the results of running Rollup across a Stencil project
109+
* @param bundleModules lazy-loadable modules that can be resolved at runtime
110+
*/
111+
const addStaticImports = (rollupChunkResults: d.RollupChunkResult[], bundleModules: d.BundleModule[]): void => {
112+
rollupChunkResults.filter(isStencilCoreResult).forEach((index: d.RollupChunkResult) => {
113+
const generateCjs = isCjsFormat(index) ? generateCaseClauseCjs : generateCaseClause;
114+
index.code = index.code.replace(
115+
'/*!__STENCIL_STATIC_IMPORT_SWITCH__*/',
116+
`
117+
if (!hmrVersionId || !BUILD.hotModuleReplacement) {
118+
const processMod = importedModule => {
119+
cmpModules.set(bundleId, importedModule);
120+
return importedModule[exportName];
121+
}
122+
switch(bundleId) {
123+
${bundleModules.map((mod) => generateCjs(mod.output.bundleId)).join('')}
124+
}
125+
}`
126+
);
127+
});
128+
};
129+
130+
/**
131+
* Determine if a Rollup output chunk contains Stencil runtime code
132+
* @param rollupChunkResult the rollup chunk output to test
133+
* @returns true if the output chunk contains Stencil runtime code, false otherwise
134+
*/
135+
const isStencilCoreResult = (rollupChunkResult: d.RollupChunkResult): boolean => {
136+
return (
137+
rollupChunkResult.isCore &&
138+
rollupChunkResult.entryKey === 'index' &&
139+
(rollupChunkResult.moduleFormat === 'es' ||
140+
rollupChunkResult.moduleFormat === 'esm' ||
141+
isCjsFormat(rollupChunkResult))
142+
);
143+
};
144+
145+
/**
146+
* Helper function to determine if a Rollup chunk has a commonjs module format
147+
* @param rollupChunkResult the Rollup result to test
148+
* @returns true if the Rollup chunk has a commonjs module format, false otherwise
149+
*/
150+
const isCjsFormat = (rollupChunkResult: d.RollupChunkResult): boolean => {
151+
return rollupChunkResult.moduleFormat === 'cjs' || rollupChunkResult.moduleFormat === 'commonjs';
152+
};
153+
154+
/**
155+
* Generate a 'case' clause to be used within a `switch` statement. The case clause generated will key-off the provided
156+
* bundle ID for a component, and load a file (tied to that ID) at runtime.
157+
* @param bundleId the name of the bundle to load
158+
* @returns the case clause that will load the component's file at runtime
159+
*/
160+
const generateCaseClause = (bundleId: string): string => {
161+
return `
162+
case '${bundleId}':
163+
return import(
164+
/* webpackMode: "lazy" */
165+
'./${bundleId}.entry.js').then(processMod, consoleError);`;
166+
};
167+
168+
/**
169+
* Generate a 'case' clause to be used within a `switch` statement. The case clause generated will key-off the provided
170+
* bundle ID for a component, and load a CommonJS file (tied to that ID) at runtime.
171+
* @param bundleId the name of the bundle to load
172+
* @returns the case clause that will load the component's file at runtime
173+
*/
174+
const generateCaseClauseCjs = (bundleId: string): string => {
175+
return `
176+
case '${bundleId}':
177+
return Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require(
178+
/* webpackMode: "lazy" */
179+
'./${bundleId}.entry.js')); }).then(processMod, consoleError);`;
180+
};
181+
101182
const generateLazyEntryModule = async (
102183
config: d.Config,
103184
compilerCtx: d.CompilerCtx,

src/declarations/stencil-public-compiler.ts

+8
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,14 @@ export interface ConfigExtras {
277277
*/
278278
dynamicImportShim?: boolean;
279279

280+
/**
281+
* Experimental flag. Projects that use a Stencil library built using the `dist` output target may have trouble lazily
282+
* loading components when using a bundler such as Vite or Parcel. Setting this flag to `true` will change how Stencil
283+
* lazily loads components in a way that works with additional bundlers. Setting this flag to `true` will increase
284+
* the size of the compiled output. Defaults to `false`.
285+
*/
286+
experimentalImportInjection?: boolean;
287+
280288
/**
281289
* Dispatches component lifecycle events. Mainly used for testing. Defaults to `false`.
282290
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
dist/
2+
loader/
3+
www/
4+
5+
node_modules/
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# component-library
2+
3+
This directory contains a small Stencil library to be consumed by other applications for testing purposes.
4+
5+
The library consists of a single component, `<my-component></my-component>`.
6+
Documentation for using this component can be found in the [README.md file](./src/components/my-component/readme.md) for
7+
the component.
8+
9+
## scripts
10+
11+
This library contains three NPM scripts:
12+
13+
- `build` - builds the project for use in other applications
14+
- `clean` - removes previously created build artifacts
15+
- `start` - starts up a local dev server to validate the component looks/behaves as expected (without having to
16+
consume it in an application)

test/bundler/component-library/package-lock.json

+13
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "component-library",
3+
"version": "0.0.1",
4+
"description": "Stencil Component Starter",
5+
"main": "dist/index.cjs.js",
6+
"module": "dist/index.js",
7+
"es2015": "dist/esm/index.mjs",
8+
"es2017": "dist/esm/index.mjs",
9+
"types": "dist/types/index.d.ts",
10+
"collection": "dist/collection/collection-manifest.json",
11+
"collection:main": "dist/collection/index.js",
12+
"unpkg": "dist/component-library/component-library.esm.js",
13+
"files": [
14+
"dist/",
15+
"loader/"
16+
],
17+
"scripts": {
18+
"build": "node ../../../bin/stencil build --docs",
19+
"clean": "rm -rf dist loader www",
20+
"start": "node ../../../bin/stencil build --dev --watch --serve"
21+
},
22+
"license": "MIT"
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/* eslint-disable */
2+
/* tslint:disable */
3+
/**
4+
* This is an autogenerated file created by the Stencil compiler.
5+
* It contains typing information for all components that exist in this project.
6+
*/
7+
import { HTMLStencilElement, JSXBase } from "@stencil/core/internal";
8+
export namespace Components {
9+
interface MyComponent {
10+
/**
11+
* The first name
12+
*/
13+
"first": string;
14+
/**
15+
* The last name
16+
*/
17+
"last": string;
18+
/**
19+
* The middle name
20+
*/
21+
"middle": string;
22+
}
23+
}
24+
declare global {
25+
interface HTMLMyComponentElement extends Components.MyComponent, HTMLStencilElement {
26+
}
27+
var HTMLMyComponentElement: {
28+
prototype: HTMLMyComponentElement;
29+
new (): HTMLMyComponentElement;
30+
};
31+
interface HTMLElementTagNameMap {
32+
"my-component": HTMLMyComponentElement;
33+
}
34+
}
35+
declare namespace LocalJSX {
36+
interface MyComponent {
37+
/**
38+
* The first name
39+
*/
40+
"first"?: string;
41+
/**
42+
* The last name
43+
*/
44+
"last"?: string;
45+
/**
46+
* The middle name
47+
*/
48+
"middle"?: string;
49+
}
50+
interface IntrinsicElements {
51+
"my-component": MyComponent;
52+
}
53+
}
54+
export { LocalJSX as JSX };
55+
declare module "@stencil/core" {
56+
export namespace JSX {
57+
interface IntrinsicElements {
58+
"my-component": LocalJSX.MyComponent & JSXBase.HTMLAttributes<HTMLMyComponentElement>;
59+
}
60+
}
61+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:host {
2+
display: block;
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Component, Prop, h } from '@stencil/core';
2+
import { format } from '../../utils/utils';
3+
4+
@Component({
5+
tag: 'my-component',
6+
styleUrl: 'my-component.css',
7+
shadow: true,
8+
})
9+
export class MyComponent {
10+
/**
11+
* The first name
12+
*/
13+
@Prop() first: string;
14+
15+
/**
16+
* The middle name
17+
*/
18+
@Prop() middle: string;
19+
20+
/**
21+
* The last name
22+
*/
23+
@Prop() last: string;
24+
25+
private getText(): string {
26+
return format(this.first, this.middle, this.last);
27+
}
28+
29+
render() {
30+
return <div>Hello, World! I'm {this.getText()}</div>;
31+
}
32+
}

0 commit comments

Comments
 (0)