Skip to content
Open
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
96 changes: 87 additions & 9 deletions src/compiler/transformers/static-to-meta/class-extension.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
142 changes: 142 additions & 0 deletions src/compiler/transformers/test/class-extension.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading