Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(types): components.d.ts type resolution for duplicate types #3337

Merged
merged 3 commits into from
Apr 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/compiler/types/generate-app-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const generateComponentTypesFile = (config: d.Config, buildCtx: d.BuildCtx, areT
* grow as more components (with additional types) are processed.
*/
typeImportData = updateReferenceTypeImports(typeImportData, allTypes, cmp, cmp.sourceFilePath);
return generateComponentTypes(cmp, areTypesInternal);
return generateComponentTypes(cmp, typeImportData, areTypesInternal);
});

c.push(COMPONENTS_DTS_HEADER);
Expand Down
13 changes: 9 additions & 4 deletions src/compiler/types/generate-component-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,22 @@ import { generatePropTypes } from './generate-prop-types';
/**
* Generate a string based on the types that are defined within a component
* @param cmp the metadata for the component that a type definition string is generated for
* @param typeImportData locally/imported/globally used type names, which may be used to prevent naming collisions
* @param areTypesInternal `true` if types being generated are for a project's internal purposes, `false` otherwise
* @returns the generated types string alongside additional metadata
*/
export const generateComponentTypes = (cmp: d.ComponentCompilerMeta, areTypesInternal: boolean): d.TypesModule => {
export const generateComponentTypes = (
cmp: d.ComponentCompilerMeta,
typeImportData: d.TypesImportData,
areTypesInternal: boolean
): d.TypesModule => {
const tagName = cmp.tagName.toLowerCase();
const tagNameAsPascal = dashToPascalCase(tagName);
const htmlElementName = `HTML${tagNameAsPascal}Element`;

const propAttributes = generatePropTypes(cmp);
const methodAttributes = generateMethodTypes(cmp);
const eventAttributes = generateEventTypes(cmp);
const propAttributes = generatePropTypes(cmp, typeImportData);
const methodAttributes = generateMethodTypes(cmp, typeImportData);
const eventAttributes = generateEventTypes(cmp, typeImportData);

const componentAttributes = attributesToMultiLineString(
[...propAttributes, ...methodAttributes],
Expand Down
32 changes: 28 additions & 4 deletions src/compiler/types/generate-event-types.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import type * as d from '../../declarations';
import { getTextDocs, toTitleCase } from '@utils';
import { updateTypeIdentifierNames } from './stencil-types';

/**
* Generates the individual event types for all @Event() decorated events in a component
* @param cmpMeta component runtime metadata for a single component
* @param typeImportData locally/imported/globally used type names, which may be used to prevent naming collisions
* @returns the generated type metadata
*/
export const generateEventTypes = (cmpMeta: d.ComponentCompilerMeta): d.TypeInfo => {
export const generateEventTypes = (cmpMeta: d.ComponentCompilerMeta, typeImportData: d.TypesImportData): d.TypeInfo => {
return cmpMeta.events.map((cmpEvent) => {
const name = `on${toTitleCase(cmpEvent.name)}`;
const type = cmpEvent.complexType.original
? `(event: CustomEvent<${cmpEvent.complexType.original}>) => void`
: `CustomEvent`;
const type = getEventType(cmpEvent, typeImportData, cmpMeta.sourceFilePath);
return {
name,
type,
Expand All @@ -22,3 +22,27 @@ export const generateEventTypes = (cmpMeta: d.ComponentCompilerMeta): d.TypeInfo
};
});
};

/**
* Determine the correct type name for all type(s) used by a class member annotated with `@Event()`
* @param cmpEvent the compiler metadata for a single `@Event()`
* @param typeImportData locally/imported/globally used type names, which may be used to prevent naming collisions
* @param componentSourcePath the path to the component on disk
* @returns the type associated with a `@Event()`
*/
const getEventType = (
cmpEvent: d.ComponentCompilerEvent,
typeImportData: d.TypesImportData,
componentSourcePath: string
): string => {
if (!cmpEvent.complexType.original) {
return 'CustomEvent';
}
const updatedTypeName = updateTypeIdentifierNames(
cmpEvent.complexType.references,
typeImportData,
componentSourcePath,
cmpEvent.complexType.original
);
return `(event: CustomEvent<${updatedTypeName}>) => void`;
};
29 changes: 27 additions & 2 deletions src/compiler/types/generate-method-types.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
import type * as d from '../../declarations';
import { getTextDocs } from '@utils';
import { updateTypeIdentifierNames } from './stencil-types';

/**
* Generates the individual event types for all @Method() decorated events in a component
* @param cmpMeta component runtime metadata for a single component
* @param typeImportData locally/imported/globally used type names, which may be used to prevent naming collisions
* @returns the generated type metadata
*/
export const generateMethodTypes = (cmpMeta: d.ComponentCompilerMeta): d.TypeInfo => {
export const generateMethodTypes = (
cmpMeta: d.ComponentCompilerMeta,
typeImportData: d.TypesImportData
): d.TypeInfo => {
return cmpMeta.methods.map((cmpMethod) => ({
name: cmpMethod.name,
type: cmpMethod.complexType.signature,
type: getType(cmpMethod, typeImportData, cmpMeta.sourceFilePath),
optional: false,
required: false,
internal: cmpMethod.internal,
jsdoc: getTextDocs(cmpMethod.docs),
}));
};

/**
* Determine the correct type name for all type(s) used by a class member annotated with `@Method()`
* @param cmpMethod the compiler metadata for a single `@Method()`
* @param typeImportData locally/imported/globally used type names, which may be used to prevent naming collisions
* @param componentSourcePath the path to the component on disk
* @returns the type associated with a `@Method()`
*/
function getType(
cmpMethod: d.ComponentCompilerMethod,
typeImportData: d.TypesImportData,
componentSourcePath: string
): string {
return updateTypeIdentifierNames(
cmpMethod.complexType.references,
typeImportData,
componentSourcePath,
cmpMethod.complexType.signature
);
}
26 changes: 24 additions & 2 deletions src/compiler/types/generate-prop-types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import type * as d from '../../declarations';
import { getTextDocs } from '@utils';
import { updateTypeIdentifierNames } from './stencil-types';

/**
* Generates the individual event types for all @Prop() decorated events in a component
* @param cmpMeta component runtime metadata for a single component
* @param typeImportData locally/imported/globally used type names, which may be used to prevent naming collisions
* @returns the generated type metadata
*/
export const generatePropTypes = (cmpMeta: d.ComponentCompilerMeta): d.TypeInfo => {
export const generatePropTypes = (cmpMeta: d.ComponentCompilerMeta, typeImportData: d.TypesImportData): d.TypeInfo => {
return [
...cmpMeta.properties.map((cmpProp) => ({
name: cmpProp.name,
type: cmpProp.complexType.original,
type: getType(cmpProp, typeImportData, cmpMeta.sourceFilePath),
optional: cmpProp.optional,
required: cmpProp.required,
internal: cmpProp.internal,
Expand All @@ -26,3 +28,23 @@ export const generatePropTypes = (cmpMeta: d.ComponentCompilerMeta): d.TypeInfo
})),
];
};

/**
* Determine the correct type name for all type(s) used by a class member annotated with `@Prop()`
* @param cmpProp the compiler metadata for a single `@Prop()`
* @param typeImportData locally/imported/globally used type names, which may be used to prevent naming collisions
* @param componentSourcePath the path to the component on disk
* @returns the type associated with a `@Prop()`
*/
function getType(
cmpProp: d.ComponentCompilerProperty,
typeImportData: d.TypesImportData,
componentSourcePath: string
): string {
return updateTypeIdentifierNames(
cmpProp.complexType.references,
typeImportData,
componentSourcePath,
cmpProp.complexType.original
);
}
77 changes: 76 additions & 1 deletion src/compiler/types/stencil-types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type * as d from '../../declarations';
import { dirname, join, relative } from 'path';
import { dirname, join, relative, resolve } from 'path';
import { isOutputTargetDistTypes } from '../output-targets/output-utils';
import { normalizePath } from '@utils';

Expand Down Expand Up @@ -30,6 +30,81 @@ export const updateStencilTypesImports = (typesDir: string, dtsFilePath: string,
return dtsContent;
};

/**
* Utility for ensuring that naming collisions do not appear in type declaration files for a component's class members
* decorated with @Prop, @Event, and @Method
* @param typeReferences all type names used by a component class member
* @param typeImportData locally/imported/globally used type names, which may be used to prevent naming collisions
* @param sourceFilePath the path to the source file of a component using the type being inspected
* @param initialType the name of the type that may be updated
* @returns the updated type name, which may be the same as the initial type name provided as an argument to this
* function
*/
export const updateTypeIdentifierNames = (
typeReferences: d.ComponentCompilerTypeReferences,
typeImportData: d.TypesImportData,
sourceFilePath: string,
initialType: string
): string => {
let currentTypeName = initialType;

// iterate over each of the type references, as there may be >1 reference to inspect
for (let typeReference of Object.values(typeReferences)) {
const importResolvedFile = getTypeImportPath(typeReference.path, sourceFilePath);

if (!typeImportData.hasOwnProperty(importResolvedFile)) {
continue;
}

for (let typesImportDatumElement of typeImportData[importResolvedFile]) {
currentTypeName = updateTypeName(currentTypeName, typesImportDatumElement);
}
}
return currentTypeName;
Copy link
Contributor

@alicewriteswrongs alicewriteswrongs Apr 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one thing I'm wondering - this function is going to be called for every component prop, every event, and so on, and then for each of those we're doing two nested for loops. Are we worried about the performance impact of doing that? Do you think it's worthwhile / necessary to do a little measurement to see if there's an impact? When I see a for ... of within a for ... of and then realize that this function is itself called within a loop I wonder, but I don't feel like I have enough context to know how big the things we're looping through are going to be. So possibly not a concern, but wanted to flag it to see your thoughts and make sure I'm understanding what's going on.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we worried about the performance impact of doing that?

A little, yes. Specifically, I'm concerned about the cost of these two lines in updateTypeName:

  const typeNameRegex = new RegExp(`${typeAlias.localName}\\b${endingStrChar}`, 'g');
  return currentTypeName.replace(typeNameRegex, typeAlias.importName);

where we build this regex and run replace globally on currentTypeName. I tried to add as many cases where we can avoid this work as possible (if there's no alias to replace a collision with, if we don't see any collisions at all, etc.), but we still see something like O(m * n * p) performance where:

  • m is the size of ComponentCompilerTypeReferences
  • n is the size of TypesImportData
  • p is the length of the string type of the collision (maybe, the perf implications may be worse here with allocating memory for a new string)

My hope is the m and n are relatively small since we evaluate this on a per component (and therefore per file) basis. p is a bit trickier to optimize ATM, and is going to require some rework that I planned in STENCIL-418+STENCIL-419 (we'd need to move some of this cost to allocating/storing the actual types instead of flattening them to strings).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok that all makes sense, I was thinking that given that we're going through a component at a time it shouldn't be a huge issue, but if stencil users have some enormous components with 30 or 40 imports we might see some real impact there.

Incidentally, this seems like exactly the type of situation where some automated performance testing against a large stencil project would be super handy!

};

/**
* Determine the path of a given type reference, relative to the path of a source file
* @param importResolvedFile the path to the file containing the resolve type. may be absolute or relative
* @param sourceFilePath the component source file path to resolve against
* @returns the path of the type import
*/
const getTypeImportPath = (importResolvedFile: string | undefined, sourceFilePath: string): string => {
const isPathRelative = importResolvedFile && importResolvedFile.startsWith('.');
if (isPathRelative) {
importResolvedFile = resolve(dirname(sourceFilePath), importResolvedFile);
}

return importResolvedFile;
};

/**
* Determine whether the string representation of a type should be replaced with an alias
* @param currentTypeName the current string representation of a type
* @param typeAlias a type member and a potential different name associated with the type member
* @returns the updated string representation of a type. If the type is not updated, the original type name is returned
*/
const updateTypeName = (currentTypeName: string, typeAlias: d.TypesMemberNameData): string => {
if (!typeAlias.importName) {
return currentTypeName;
}

// TODO(STENCIL-419): Update this functionality to no longer use a regex
// negative lookahead specifying that quotes that designate a string in JavaScript cannot follow some expression
const endingStrChar = '(?!("|\'|`))';
/**
* A regular expression that looks at type names along a [word boundary](https://www.regular-expressions.info/wordboundaries.html).
* This is used as the best approximation for replacing type collisions, as this stage of compilation has only
* 'flattened' type information in the form of a String.
*
* This regex should be expected to capture types that are found in generics, unions, intersections, etc., but not
* those in string literals. We do not check for a starting quote (" | ' | `) here as some browsers do not support
* negative lookbehind. This works "well enough" until STENCIL-419 is completed.
*/
const typeNameRegex = new RegExp(`${typeAlias.localName}\\b${endingStrChar}`, 'g');
return currentTypeName.replace(typeNameRegex, typeAlias.importName);
};

/**
* Writes Stencil core typings file to disk for a dist-* output target
* @param config the Stencil configuration associated with the project being compiled
Expand Down
18 changes: 18 additions & 0 deletions src/compiler/types/tests/TypesImportData.stub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as d from '@stencil/core/declarations';

/**
* Generates a stub {@link TypesImportData}.
* @param overrides a partial implementation of `TypesImportData`. Any provided fields will override the defaults
* provided by this function.
* @returns the stubbed `TypesImportData`
*/
export const stubTypesImportData = (overrides: Partial<d.TypesImportData> = {}): d.TypesImportData => {
/**
* By design, we do not provide any default values. the keys used in this data structure will be highly dependent on
* the tests being written, and providing default values may lead to unexpected behavior when enumerating the returned
* stub
*/
const defaults: d.TypesImportData = {};

return { ...defaults, ...overrides };
};
Loading