diff --git a/src/compiler/transformers/static-to-meta/class-extension.ts b/src/compiler/transformers/static-to-meta/class-extension.ts index c608f9ba3ad..5cad97c1ba2 100644 --- a/src/compiler/transformers/static-to-meta/class-extension.ts +++ b/src/compiler/transformers/static-to-meta/class-extension.ts @@ -1,17 +1,18 @@ +import { augmentDiagnosticWithNode, buildWarn, join, normalizePath, relative } from '@utils'; +import { dirname } from 'path'; import ts from 'typescript'; -import { augmentDiagnosticWithNode, buildWarn, normalizePath } from '@utils'; -import { tsResolveModuleName, tsGetSourceFile } from '../../sys/typescript/typescript-resolve-module'; + +import type * as d from '../../../declarations'; +import { tsGetSourceFile, tsResolveModuleName } from '../../sys/typescript/typescript-resolve-module'; +import { detectModernPropDeclarations } from '../detect-modern-prop-decls'; import { isStaticGetter } from '../transform-utils'; import { parseStaticEvents } from './events'; import { parseStaticListeners } from './listeners'; import { parseStaticMethods } from './methods'; import { parseStaticProps } from './props'; +import { parseStaticSerializers } from './serializers'; import { parseStaticStates } from './states'; import { parseStaticWatchers } from './watchers'; -import { parseStaticSerializers } from './serializers'; - -import type * as d from '../../../declarations'; -import { detectModernPropDeclarations } from '../detect-modern-prop-decls'; type DeDupeMember = | d.ComponentCompilerProperty @@ -431,6 +432,71 @@ function buildExtendsTree( return dependentClasses; } +/** + * Ensure a module specifier is explicitly relative (i.e. starts with `./` or `../`) + * + * @param specifier a module specifier + * @returns the specifier, prefixed with `./` if it was not already explicitly relative + */ +const ensureRelativeSpecifier = (specifier: string): string => { + return specifier.startsWith('.') ? specifier : `./${specifier}`; +}; + +/** + * Rewrite the type references of members inherited from an extended class so that + * they resolve from the extending component's file. + * + * Type references are recorded when a class is parsed, so their `path`s are relative + * to the file declaring that class. When members are merged into a component from an + * extended class living in a different directory, those specifiers no longer resolve + * from the component's file - which is what consumers like `components.d.ts` + * generation resolve them against. This re-anchors: + * + * - `import` references with a relative specifier: rewritten to be relative to the + * component's directory + * - `local` references (types declared in the extended class's own file): converted + * to `import` references pointing at the extended class's file, since from the + * component's point of view the type lives in another module + * + * @param members the inherited members whose complex type references should be re-anchored + * @param extendedClassFileName the absolute path of the file declaring the extended class + * @param cmpSourceFilePath the absolute path of the extending component's source file + * @returns the same members, with their type references re-anchored + */ +export const reanchorInheritedTypeReferences = < + T extends d.ComponentCompilerProperty | d.ComponentCompilerEvent | d.ComponentCompilerMethod, +>( + members: T[], + extendedClassFileName: string, + cmpSourceFilePath: string, +): T[] => { + const extendedClassDir = dirname(normalizePath(extendedClassFileName, false)); + const cmpDir = dirname(normalizePath(cmpSourceFilePath, false)); + if (extendedClassDir === cmpDir || extendedClassFileName.includes('node_modules')) { + // specifiers already resolve correctly from the component's directory + // (or the extended class ships in an external collection, where relative + // specifiers cannot be re-anchored onto the consuming project) + return members; + } + members.forEach((member) => { + const references = member.complexType?.references; + if (!references) { + return; + } + Object.values(references).forEach((reference) => { + if (reference.location === 'import' && reference.path?.startsWith('.')) { + const typeModulePath = join(extendedClassDir, reference.path); + reference.path = ensureRelativeSpecifier(relative(cmpDir, typeModulePath)); + } else if (reference.location === 'local') { + const extendedClassModule = normalizePath(extendedClassFileName, false).replace(/\.(tsx|ts)$/, ''); + reference.location = 'import'; + reference.path = ensureRelativeSpecifier(relative(cmpDir, extendedClassModule)); + } + }); + }); + return members; +}; + /** * Given a class declaration, this function will analyze its heritage clauses * to find any extended classes, and then parse the static members of those @@ -467,10 +533,22 @@ export function mergeExtendedClassMeta( tree.forEach((extendedClass) => { const extendedStaticMembers = extendedClass.classNode.members.filter(isStaticGetter); - const mixinProps = parseStaticProps(extendedStaticMembers) ?? []; + const mixinProps = reanchorInheritedTypeReferences( + parseStaticProps(extendedStaticMembers) ?? [], + extendedClass.fileName, + moduleFile.sourceFilePath, + ); const mixinStates = parseStaticStates(extendedStaticMembers) ?? []; - const mixinMethods = parseStaticMethods(extendedStaticMembers) ?? []; - const mixinEvents = parseStaticEvents(extendedStaticMembers) ?? []; + const mixinMethods = reanchorInheritedTypeReferences( + parseStaticMethods(extendedStaticMembers) ?? [], + extendedClass.fileName, + moduleFile.sourceFilePath, + ); + const mixinEvents = reanchorInheritedTypeReferences( + parseStaticEvents(extendedStaticMembers) ?? [], + extendedClass.fileName, + moduleFile.sourceFilePath, + ); const isMixin = mixinProps.length > 0 || mixinStates.length > 0 || mixinMethods.length > 0 || mixinEvents.length > 0; const module = compilerCtx.moduleMap.get(extendedClass.fileName); diff --git a/src/compiler/transformers/test/class-extension.spec.ts b/src/compiler/transformers/test/class-extension.spec.ts new file mode 100644 index 00000000000..f5b71096379 --- /dev/null +++ b/src/compiler/transformers/test/class-extension.spec.ts @@ -0,0 +1,170 @@ +import type * as d from '../../../declarations'; +import { reanchorInheritedTypeReferences } from '../static-to-meta/class-extension'; + +describe('class-extension', () => { + describe('reanchorInheritedTypeReferences', () => { + const CMP_PATH = '/src/components/data-entry/checkbox/checkbox.tsx'; + const BASE_CLASS_PATH = '/src/components/shared/input/base-input.ts'; + + const buildProperty = (references: d.ComponentCompilerTypeReferences): d.ComponentCompilerProperty => + ({ + name: 'validator', + complexType: { + original: 'Validator', + resolved: 'Validator', + references, + }, + }) as d.ComponentCompilerProperty; + + it('re-anchors a relative import reference onto the component directory', () => { + const property = buildProperty({ + Validator: { + location: 'import', + path: './input.types', + id: 'src/components/shared/input/input.types.ts::Validator', + }, + }); + + reanchorInheritedTypeReferences([property], BASE_CLASS_PATH, CMP_PATH); + + expect(property.complexType.references['Validator']).toEqual({ + location: 'import', + path: '../../shared/input/input.types', + id: 'src/components/shared/input/input.types.ts::Validator', + }); + }); + + // Regression test for https://github.com/stenciljs/core/issues/6700: an abstract + // base class living in a subdirectory of the component imports a type via a relative + // specifier that escapes back up into the component's own directory. The re-anchored + // specifier must resolve from the component's directory so the generated + // components.d.ts import is correct for the extending component. + it('re-anchors a relative import when the extended class is in a subdirectory of the component', () => { + const property = buildProperty({ + UtilType: { + location: 'import', + // relative to the base class in `shared/`, this resolves to /src/components/util-types + path: '../util-types', + id: 'src/components/util-types.ts::UtilType', + }, + }); + + reanchorInheritedTypeReferences( + [property], + '/src/components/shared/abstract-component.tsx', + '/src/components/sub-component.tsx', + ); + + expect(property.complexType.references['UtilType']).toEqual({ + location: 'import', + path: './util-types', + id: 'src/components/util-types.ts::UtilType', + }); + }); + + it('re-anchors a relative import reference pointing outside the extended class directory', () => { + const property = buildProperty({ + Validator: { + location: 'import', + // resolves to /src/utils/validation.types from the base class's directory + path: '../../../utils/validation.types', + id: 'src/utils/validation.types.ts::Validator', + }, + }); + + reanchorInheritedTypeReferences([property], BASE_CLASS_PATH, CMP_PATH); + + expect(property.complexType.references['Validator'].path).toBe('../../../utils/validation.types'); + }); + + it('converts a local reference into an import of the extended class module', () => { + const property = buildProperty({ + InputSize: { + location: 'local', + path: BASE_CLASS_PATH, + id: 'src/components/shared/input/base-input.ts::InputSize', + }, + }); + + reanchorInheritedTypeReferences([property], BASE_CLASS_PATH, CMP_PATH); + + expect(property.complexType.references['InputSize']).toEqual({ + location: 'import', + path: '../../shared/input/base-input', + id: 'src/components/shared/input/base-input.ts::InputSize', + }); + }); + + it('prefixes "./" when the re-anchored specifier is not explicitly relative', () => { + const property = buildProperty({ + Validator: { + location: 'import', + path: './input.types', + id: 'src/components/input.types.ts::Validator', + }, + }); + + // base class in a parent directory of the component + reanchorInheritedTypeReferences([property], '/src/components/base-input.ts', '/src/checkbox.tsx'); + + expect(property.complexType.references['Validator'].path).toBe('./components/input.types'); + }); + + it('leaves package import references untouched', () => { + const reference: d.ComponentCompilerTypeReference = { + location: 'import', + path: '@my-org/types', + id: 'node_modules::Validator', + }; + const property = buildProperty({ Validator: { ...reference } }); + + reanchorInheritedTypeReferences([property], BASE_CLASS_PATH, CMP_PATH); + + expect(property.complexType.references['Validator']).toEqual(reference); + }); + + it('leaves global references untouched', () => { + const reference: d.ComponentCompilerTypeReference = { + location: 'global', + id: 'global::HTMLElement', + }; + const property = buildProperty({ HTMLElement: { ...reference } }); + + reanchorInheritedTypeReferences([property], BASE_CLASS_PATH, CMP_PATH); + + expect(property.complexType.references['HTMLElement']).toEqual(reference); + }); + + it('does not rewrite references when the extended class lives in the same directory', () => { + const reference: d.ComponentCompilerTypeReference = { + location: 'import', + path: './input.types', + id: 'src/components/data-entry/checkbox/input.types.ts::Validator', + }; + const property = buildProperty({ Validator: { ...reference } }); + + reanchorInheritedTypeReferences([property], '/src/components/data-entry/checkbox/base.ts', CMP_PATH); + + expect(property.complexType.references['Validator']).toEqual(reference); + }); + + it('does not rewrite references when the extended class comes from node_modules', () => { + const reference: d.ComponentCompilerTypeReference = { + location: 'import', + path: './input.types', + id: 'node_modules::Validator', + }; + const property = buildProperty({ Validator: { ...reference } }); + + reanchorInheritedTypeReferences([property], '/node_modules/@my-org/core/dist/collection/base-input.js', CMP_PATH); + + expect(property.complexType.references['Validator']).toEqual(reference); + }); + + it('handles members without complex type references', () => { + const method = { name: 'doSomething' } as d.ComponentCompilerMethod; + + expect(() => reanchorInheritedTypeReferences([method], BASE_CLASS_PATH, CMP_PATH)).not.toThrow(); + }); + }); +});