From 83d0c3746f7e4ad1b3f1fb88dbf8323edf98e8ee Mon Sep 17 00:00:00 2001 From: Arul1998 Date: Fri, 17 Jul 2026 19:19:28 +0100 Subject: [PATCH 1/2] fix(compiler): resolve inherited type references against the declaring file Type references on component members are recorded relative to the file declaring them. When members are inherited from a base class in another directory, the references were merged into the component's metadata unchanged, and type generation later resolved them against the component's own file. This produced imports in components.d.ts pointing at nonexistent paths, broke type resolution for library consumers, and emitted duplicate aliased imports for each extending component. This affected both imported types and types declared locally in the base class's file, with any level of inheritance. Re-anchor inherited members' type references when they are merged: relative import specifiers are rewritten to resolve from the component's directory, and base-class-local types become imports of the base class's module. fixes #6687 --- .../static-to-meta/class-extension.ts | 96 ++++++++++-- .../transformers/test/class-extension.spec.ts | 142 ++++++++++++++++++ 2 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 src/compiler/transformers/test/class-extension.spec.ts 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..81e278b0fd3 --- /dev/null +++ b/src/compiler/transformers/test/class-extension.spec.ts @@ -0,0 +1,142 @@ +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', + }); + }); + + 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(); + }); + }); +}); From 40e1632b67f98010dd0d721ddb667061a18cb87b Mon Sep 17 00:00:00 2001 From: Arul1998 Date: Mon, 27 Jul 2026 16:29:04 +0100 Subject: [PATCH 2/2] test(compiler): add regression coverage for issue #6700 --- .../transformers/test/class-extension.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/compiler/transformers/test/class-extension.spec.ts b/src/compiler/transformers/test/class-extension.spec.ts index 81e278b0fd3..f5b71096379 100644 --- a/src/compiler/transformers/test/class-extension.spec.ts +++ b/src/compiler/transformers/test/class-extension.spec.ts @@ -34,6 +34,34 @@ describe('class-extension', () => { }); }); + // 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: {