Skip to content

Commit

Permalink
feat(compiler): add defineCustomElements method & signature typedef (
Browse files Browse the repository at this point in the history
…#3619)

* feat(compiler): moves `autoDefineCustomElements` to an export behavior

This commit moves `autoDefineCustomElements` from a config flag to a `customElementsExportBehavior` option on the `dist-custom-elements` output target. This prevents treeshaking issues that were possible when barrel exporting and this option were both enabled.

* feat(compiler): add `defineCustomElements` method & signature typedef

This commit adds a `defineCustomElements` function to the `dist-custom-elements` output target that can be used to define all custom elements at once. TBD on if this will always be available, for certain export behaviors, or as a dedicated export behavior

* feat(compiler): add export behavior for custom elements `defineCustomElements`

This commit wraps the `defineCustomElements()` function for `dist-custom-elements` into a new export behavior so this becomes opt-in behavior rather than behavior that always exists

* test(compiler): tests for custom elements `bundle` export behavior

This commit adds test cases for the new `bundle` export behavior on `dist-custom-elements` that will serve as a quick port-over for projects currently using `dist-custom-elements-bundle`

* misc(): fix jest alias lint error

* fix(): PR feedback

* refactor(compiler): move export behavior conditionals to entry point generation

This commit moves the logic for `dist-custom-elements` export behavior to the `generateEntryPoint()` function. This keeps all the logic responsible for generating entry-point code in the same place and will make it easier to add/remove export behaviors in the future

* misc(compiler): add line break before body content in custom element output

* fix(): failing tests due to code format output change
  • Loading branch information
tanner-reits committed Oct 13, 2022
1 parent e52489e commit 7521e17
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const generateCustomElementsTypesOutput = async (
outputTarget: d.OutputTargetDistCustomElements
) => {
const isBarrelExport = outputTarget.customElementsExportBehavior === 'single-export-module';
const isBundleExport = outputTarget.customElementsExportBehavior === 'bundle';

// the path where we're going to write the typedef for the whole dist-custom-elements output
const customElementsDtsPath = join(outputTarget.dir!, 'index.d.ts');
Expand Down Expand Up @@ -97,6 +98,22 @@ const generateCustomElementsTypesOutput = async (
` rel?: (el: EventTarget, eventName: string, listener: EventListenerOrEventListenerObject, options: boolean | AddEventListenerOptions) => void;`,
`}`,
`export declare const setPlatformOptions: (opts: SetPlatformOptions) => void;`,
...(isBundleExport
? [
``,
`/**`,
` * Utility to define all custom elements within this package using the tag name provided in the component's source.`,
` * When defining each custom element, it will also check it's safe to define by:`,
` *`,
` * 1. Ensuring the "customElements" registry is available in the global context (window).`,
` * 2. Ensuring that the component tag name is not already defined.`,
` *`,
` * Use the standard [customElements.define()](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define)`,
` * method instead to define custom elements individually, or to provide a different tag name.`,
` */`,
`export declare const defineCustomElements: (opts?: any) => void;`,
]
: []),
];

const componentsDtsRelPath = relDts(outputTarget.dir!, join(typesDir, 'components.d.ts'));
Expand Down
82 changes: 67 additions & 15 deletions src/compiler/output-targets/dist-custom-elements/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,7 @@ export const getBundleOptions = (
// @see {@link https://rollupjs.org/guide/en/#conventions} for more info.
index: '\0core',
},
loader: {
'\0core': generateEntryPoint(outputTarget),
},
loader: {},
inlineDynamicImports: outputTarget.inlineDynamicImports,
preserveEntrySignatures: 'allow-extension',
});
Expand Down Expand Up @@ -189,8 +187,13 @@ export const addCustomElementInputs = (
outputTarget: d.OutputTargetDistCustomElements
): void => {
const components = buildCtx.components;
// an array to store the imports of these modules that we're going to add to our entry chunk
// An array to store the imports of these modules that we're going to add to our entry chunk
const indexImports: string[] = [];
// An array to store the export declarations that we're going to add to our entry chunk
const indexExports: string[] = [];
// An array to store the exported component names that will be used for the `defineCustomElements`
// function on the `bundle` export behavior option
const exportNames: string[] = [];

components.forEach((cmp) => {
const exp: string[] = [];
Expand All @@ -201,7 +204,7 @@ export const addCustomElementInputs = (

if (cmp.isPlain) {
exp.push(`export { ${importName} as ${exportName} } from '${cmp.sourceFilePath}';`);
indexImports.push(`export { {${exportName} } from '${coreKey}';`);
indexExports.push(`export { {${exportName} } from '${coreKey}';`);
} else {
// the `importName` may collide with the `exportName`, alias it just in case it does with `importAs`
exp.push(
Expand All @@ -216,39 +219,88 @@ export const addCustomElementInputs = (
// correct virtual module, if we instead referenced, for instance,
// `cmp.sourceFilePath`, we would end up with duplicated modules in our
// output.
indexImports.push(
indexExports.push(
`export { ${exportName}, defineCustomElement as defineCustomElement${exportName} } from '${coreKey}';`
);
}

indexImports.push(`import { ${exportName} } from '${coreKey}';`);
exportNames.push(exportName);

bundleOpts.inputs[cmp.tagName] = coreKey;
bundleOpts.loader![coreKey] = exp.join('\n');
});

// Only re-export component definitions if the barrel export behavior is set
if (outputTarget.customElementsExportBehavior === 'single-export-module') {
bundleOpts.loader!['\0core'] += indexImports.join('\n');
}
// Generate the contents of the entry file to be created by the bundler
bundleOpts.loader!['\0core'] = generateEntryPoint(outputTarget, indexImports, indexExports, exportNames);
};

/**
* Generate the entrypoint (`index.ts` file) contents for the `dist-custom-elements` output target
* @param outputTarget the output target's configuration
* @param cmpImports The import declarations for local component modules.
* @param cmpExports The export declarations for local component modules.
* @param cmpNames The exported component names (could be aliased) from local component modules.
* @returns the stringified contents to be placed in the entrypoint
*/
export const generateEntryPoint = (outputTarget: d.OutputTargetDistCustomElements): string => {
const imp: string[] = [];
export const generateEntryPoint = (
outputTarget: d.OutputTargetDistCustomElements,
cmpImports: string[] = [],
cmpExports: string[] = [],
cmpNames: string[] = []
): string => {
const body: string[] = [];
const imports: string[] = [];
const exports: string[] = [];

imp.push(
// Exports that are always present
exports.push(
`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';`,
`export * from '${USER_INDEX_ENTRY_ID}';`
);

// Content related to global scripts
if (outputTarget.includeGlobalScripts !== false) {
imp.push(`import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';`, `globalScripts();`);
imports.push(`import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';`);
body.push(`globalScripts();`);
}

// Content related to the `bundle` export behavior
if (outputTarget.customElementsExportBehavior === 'bundle') {
imports.push(...cmpImports);
body.push(
'export const defineCustomElements = (opts) => {',
" if (typeof customElements !== 'undefined') {",
' [',
...cmpNames.map((cmp) => ` ${cmp},`),
' ].forEach(cmp => {',
' if (!customElements.get(cmp.is)) {',
' customElements.define(cmp.is, cmp, opts);',
' }',
' });',
' }',
'};'
);
}

return imp.join('\n') + '\n';
// Content related to the `single-export-module` export behavior
if (outputTarget.customElementsExportBehavior === 'single-export-module') {
exports.push(...cmpExports);
}

// Generate the contents of the file based on the parts
// defined above. This keeps the file structure consistent as
// new export behaviors may be added
let content = '';

// Add imports to file content
content += imports.length ? imports.join('\n') + '\n' : '';
// Add exports to file content
content += exports.length ? exports.join('\n') + '\n' : '';
// Add body to file content
content += body.length ? '\n' + body.join('\n') + '\n' : '';

return content;
};

/**
Expand Down
59 changes: 59 additions & 0 deletions src/compiler/output-targets/test/custom-elements-types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,63 @@ describe('Custom Elements Typedef generation', () => {

writeFileSpy.mockRestore();
});

it('should generate a type signature for the `defineCustomElements` function when `bundle` export behavior is set', async () => {
const componentOne = stubComponentCompilerMeta({
tagName: 'my-component',
sourceFilePath: '/src/components/my-component/my-component.tsx',
});
const componentTwo = stubComponentCompilerMeta({
sourceFilePath: '/src/components/the-other-component/my-real-best-component.tsx',
componentClassName: 'MyBestComponent',
tagName: 'my-best-component',
});
const { config, compilerCtx, buildCtx } = setup();
(config.outputTargets[0] as d.OutputTargetDistCustomElements).customElementsExportBehavior = 'bundle';
buildCtx.components = [componentOne, componentTwo];

const writeFileSpy = jest.spyOn(compilerCtx.fs, 'writeFile');

await generateCustomElementsTypes(config, compilerCtx, buildCtx, 'types_dir');

const expectedTypedefOutput = [
'/**',
' * Used to manually set the base path where assets can be found.',
' * If the script is used as "module", it\'s recommended to use "import.meta.url",',
' * such as "setAssetPath(import.meta.url)". Other options include',
' * "setAssetPath(document.currentScript.src)", or using a bundler\'s replace plugin to',
' * dynamically set the path at build time, such as "setAssetPath(process.env.ASSET_PATH)".',
' * But do note that this configuration depends on how your script is bundled, or lack of',
' * bundling, and where your assets can be loaded from. Additionally custom bundling',
' * will have to ensure the static assets are copied to its build directory.',
' */',
'export declare const setAssetPath: (path: string) => void;',
'',
'export interface SetPlatformOptions {',
' raf?: (c: FrameRequestCallback) => number;',
' ael?: (el: EventTarget, eventName: string, listener: EventListenerOrEventListenerObject, options: boolean | AddEventListenerOptions) => void;',
' rel?: (el: EventTarget, eventName: string, listener: EventListenerOrEventListenerObject, options: boolean | AddEventListenerOptions) => void;',
'}',
'export declare const setPlatformOptions: (opts: SetPlatformOptions) => void;',
'',
'/**',
` * Utility to define all custom elements within this package using the tag name provided in the component's source.`,
` * When defining each custom element, it will also check it's safe to define by:`,
' *',
' * 1. Ensuring the "customElements" registry is available in the global context (window).',
' * 2. Ensuring that the component tag name is not already defined.',
' *',
' * Use the standard [customElements.define()](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define)',
' * method instead to define custom elements individually, or to provide a different tag name.',
' */',
'export declare const defineCustomElements: (opts?: any) => void;',
'',
].join('\n');

expect(compilerCtx.fs.writeFile).toHaveBeenCalledWith(join('my-best-dir', 'index.d.ts'), expectedTypedefOutput, {
outputTargetType: DIST_CUSTOM_ELEMENTS,
});

writeFileSpy.mockRestore();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import type * as d from '../../../declarations';
import { OutputTargetDistCustomElements } from '../../../declarations';
import { STENCIL_APP_GLOBALS_ID, STENCIL_INTERNAL_CLIENT_ID, USER_INDEX_ENTRY_ID } from '../../bundle/entry-alias-ids';
import { stubComponentCompilerMeta } from '../../types/tests/ComponentCompilerMeta.stub';
import * as outputCustomElementsMod from '../dist-custom-elements';
import {
addCustomElementInputs,
bundleCustomElements,
generateEntryPoint,
getBundleOptions,
outputCustomElements,
} from '../dist-custom-elements';
import * as outputCustomElementsMod from '../dist-custom-elements';
// TODO(STENCIL-561): fully delete dist-custom-elements-bundle code
import { DIST_CUSTOM_ELEMENTS, DIST_CUSTOM_ELEMENTS_BUNDLE } from '../output-utils';

Expand Down Expand Up @@ -67,13 +67,29 @@ describe('Custom Elements output target', () => {
});

describe('generateEntryPoint', () => {
it.each([true, false])('should include globalScripts if the right option is set', (includeGlobalScripts) => {
it('should include global scripts when flag is `true`', () => {
const entryPoint = generateEntryPoint({
type: DIST_CUSTOM_ELEMENTS,
includeGlobalScripts,
includeGlobalScripts: true,
});
const globalScriptsBoilerplate = `import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';\nglobalScripts();`;
expect(entryPoint.includes(globalScriptsBoilerplate)).toBe(includeGlobalScripts);

expect(entryPoint).toEqual(`import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
export * from '${USER_INDEX_ENTRY_ID}';
globalScripts();
`);
});

it('should not include global scripts when flag is `false`', () => {
const entryPoint = generateEntryPoint({
type: DIST_CUSTOM_ELEMENTS,
includeGlobalScripts: false,
});

expect(entryPoint).toEqual(`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
export * from '${USER_INDEX_ENTRY_ID}';
`);
});
});

Expand All @@ -87,9 +103,7 @@ describe('Custom Elements output target', () => {
expect(options.inputs).toEqual({
index: '\0core',
});
expect(options.loader).toEqual({
'\0core': generateEntryPoint({ type: DIST_CUSTOM_ELEMENTS }),
});
expect(options.loader).toEqual({});
expect(options.preserveEntrySignatures).toEqual('allow-extension');
});

Expand Down Expand Up @@ -158,9 +172,10 @@ describe('Custom Elements output target', () => {
);
addCustomElementInputs(buildCtx, bundleOptions, config.outputTargets[0] as OutputTargetDistCustomElements);
expect(bundleOptions.loader['\0core']).toEqual(
`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
`import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
export * from '${USER_INDEX_ENTRY_ID}';
import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
globalScripts();
`
);
Expand Down Expand Up @@ -190,12 +205,14 @@ globalScripts();
);
addCustomElementInputs(buildCtx, bundleOptions, config.outputTargets[0] as OutputTargetDistCustomElements);
expect(bundleOptions.loader['\0core']).toEqual(
`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
`import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
export * from '${USER_INDEX_ENTRY_ID}';
import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
globalScripts();
export { StubCmp, defineCustomElement as defineCustomElementStubCmp } from '\0StubCmp';
export { MyBestComponent, defineCustomElement as defineCustomElementMyBestComponent } from '\0MyBestComponent';`
export { MyBestComponent, defineCustomElement as defineCustomElementMyBestComponent } from '\0MyBestComponent';
globalScripts();
`
);
});

Expand All @@ -215,11 +232,59 @@ export { MyBestComponent, defineCustomElement as defineCustomElementMyBestCompon
);
addCustomElementInputs(buildCtx, bundleOptions, config.outputTargets[0] as OutputTargetDistCustomElements);
expect(bundleOptions.loader['\0core']).toEqual(
`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
`import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
export * from '${USER_INDEX_ENTRY_ID}';
export { ComponentWithJsx, defineCustomElement as defineCustomElementComponentWithJsx } from '\0ComponentWithJsx';
globalScripts();
`
);
});
});

describe('CustomElementsExportBehavior.BUNDLE', () => {
beforeEach(() => {
(config.outputTargets[0] as OutputTargetDistCustomElements).customElementsExportBehavior = 'bundle';
});

it('should add a `defineCustomElements` function to the index.js file', () => {
const componentOne = stubComponentCompilerMeta();
const componentTwo = stubComponentCompilerMeta({
componentClassName: 'MyBestComponent',
tagName: 'my-best-component',
});

buildCtx.components = [componentOne, componentTwo];

const bundleOptions = getBundleOptions(
config,
buildCtx,
compilerCtx,
config.outputTargets[0] as OutputTargetDistCustomElements
);
addCustomElementInputs(buildCtx, bundleOptions, config.outputTargets[0] as OutputTargetDistCustomElements);
expect(bundleOptions.loader['\0core']).toEqual(
`import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
import { StubCmp } from '\0StubCmp';
import { MyBestComponent } from '\0MyBestComponent';
export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
export * from '${USER_INDEX_ENTRY_ID}';
import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
globalScripts();
export { ComponentWithJsx, defineCustomElement as defineCustomElementComponentWithJsx } from '\0ComponentWithJsx';`
export const defineCustomElements = (opts) => {
if (typeof customElements !== 'undefined') {
[
StubCmp,
MyBestComponent,
].forEach(cmp => {
if (!customElements.get(cmp.is)) {
customElements.define(cmp.is, cmp, opts);
}
});
}
};
`
);
});
});
Expand Down
3 changes: 3 additions & 0 deletions src/declarations/stencil-public-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2034,11 +2034,14 @@ export interface OutputTargetBaseNext {
* - `auto-define-custom-elements`: Enables the auto-definition of a component and its children (recursively) in the custom elements registry. This
* functionality allows consumers to bypass the explicit call to define a component, its children, its children's
* children, etc. Users of this flag should be aware that enabling this functionality may increase bundle size.
* - `bundle`: A `defineCustomElements` function will be exported from the distribution directory. This behavior was added to allow easy migration
* from `dist-custom-elements-bundle` to `dist-custom-elements`.
* - `single-export-module`: All components will be re-exported from the specified directory's root `index.js` file.
*/
export const CustomElementsExportBehaviorOptions = [
'default',
'auto-define-custom-elements',
'bundle',
'single-export-module',
] as const;

Expand Down

0 comments on commit 7521e17

Please sign in to comment.