Skip to content

Commit 08f6583

Browse files
johnjenkinsJohn Jenkins
andauthored
feat: new core api - Mixin (#6375)
* feat: new Mixin function * chore: prettier * chore: lint * chore: pretty much there * chore: lint * chore: spelling * chore: tidy * chore: fixup test --------- Co-authored-by: John Jenkins <john.jenkins@nanoporetech.com>
1 parent 4fb9140 commit 08f6583

39 files changed

+1367
-694
lines changed

src/compiler/transformers/detect-modern-prop-decls.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,16 @@ import { getStaticValue } from './transform-utils';
2929
* @param cmp metadata about the stencil component of interest
3030
* @returns true if the class has modern property declarations, false otherwise
3131
*/
32-
export const detectModernPropDeclarations = (classNode: ts.ClassDeclaration, cmp: d.ComponentCompilerFeatures) => {
32+
export const detectModernPropDeclarations = (classNode: ts.ClassDeclaration) => {
3333
const parsedProps: { [key: string]: d.ComponentCompilerProperty } = getStaticValue(classNode.members, 'properties');
3434
const parsedStates: { [key: string]: d.ComponentCompilerProperty } = getStaticValue(classNode.members, 'states');
3535

3636
if (!parsedProps && !parsedStates) {
37-
cmp.hasModernPropertyDecls = false;
3837
return false;
3938
}
4039

4140
const members = [...Object.entries(parsedProps || {}), ...Object.entries(parsedStates || {})];
41+
let hasModernPropertyDecls = false;
4242

4343
for (const [propName, meta] of members) {
4444
// comb through the class' body members to find a corresponding, 'modern' prop initializer
@@ -55,9 +55,9 @@ export const detectModernPropDeclarations = (classNode: ts.ClassDeclaration, cmp
5555

5656
if (!prop) continue;
5757

58-
cmp.hasModernPropertyDecls = true;
58+
hasModernPropertyDecls = true;
5959
break;
6060
}
6161

62-
return cmp.hasModernPropertyDecls;
62+
return hasModernPropertyDecls;
6363
};
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
import ts from 'typescript';
2+
import { augmentDiagnosticWithNode, buildWarn } from '@utils';
3+
import { tsResolveModuleName } from '../../sys/typescript/typescript-resolve-module';
4+
import { isStaticGetter } from '../transform-utils';
5+
import { parseStaticEvents } from './events';
6+
import { parseStaticListeners } from './listeners';
7+
import { parseStaticMethods } from './methods';
8+
import { parseStaticProps } from './props';
9+
import { parseStaticStates } from './states';
10+
import { parseStaticWatchers } from './watchers';
11+
12+
import type * as d from '../../../declarations';
13+
import { detectModernPropDeclarations } from '../detect-modern-prop-decls';
14+
15+
type DeDupeMember =
16+
| d.ComponentCompilerProperty
17+
| d.ComponentCompilerState
18+
| d.ComponentCompilerMethod
19+
| d.ComponentCompilerListener
20+
| d.ComponentCompilerEvent
21+
| d.ComponentCompilerWatch;
22+
23+
/**
24+
* Given two arrays of static members, return a new array containing only the
25+
* members from the first array that are not present in the second array.
26+
* This is used to de-dupe static members that are inherited from a parent class.
27+
*
28+
* @param dedupeMembers the array of static members to de-dupe
29+
* @param staticMembers the array of static members to compare against
30+
* @returns an array of static members that are not present in the second array
31+
*/
32+
const deDupeMembers = <T extends DeDupeMember>(dedupeMembers: T[], staticMembers: T[]) => {
33+
return dedupeMembers.filter(
34+
(s) =>
35+
!staticMembers.some((d) => {
36+
if ((d as d.ComponentCompilerWatch).methodName) {
37+
return (d as any).methodName === (s as any).methodName;
38+
}
39+
return (d as any).name === (s as any).name;
40+
}),
41+
);
42+
};
43+
44+
/**
45+
* A recursive function that walks the AST to find a class declaration.
46+
* @param node the current AST node
47+
* @param depth the current depth in the AST
48+
* @param name optional name of the class to find
49+
* @returns the found class declaration or undefined
50+
*/
51+
function findClassWalk(node?: ts.Node, name?: string): ts.ClassDeclaration | undefined {
52+
if (!node) return undefined;
53+
if (node && ts.isClassDeclaration(node) && (!name || node.name?.text === name)) {
54+
return node;
55+
}
56+
let found: ts.ClassDeclaration | undefined;
57+
58+
ts.forEachChild(node, (child) => {
59+
if (found) return;
60+
const result = findClassWalk(child, name);
61+
if (result) found = result;
62+
});
63+
64+
return found;
65+
}
66+
67+
/**
68+
* A function that checks if a statement matches a named declaration.
69+
* @param name the name to match
70+
* @returns a function that checks if a statement is a named declaration
71+
*/
72+
function matchesNamedDeclaration(name: string) {
73+
return function (stmt: ts.Statement): stmt is ts.ClassDeclaration | ts.FunctionDeclaration | ts.VariableStatement {
74+
// ClassDeclaration: class Foo {}
75+
if (ts.isClassDeclaration(stmt) && stmt.name?.text === name) {
76+
return true;
77+
}
78+
79+
// FunctionDeclaration: function Foo() {}
80+
if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === name) {
81+
return true;
82+
}
83+
84+
// VariableStatement: const Foo = ...
85+
if (ts.isVariableStatement(stmt)) {
86+
for (const decl of stmt.declarationList.declarations) {
87+
if (ts.isIdentifier(decl.name) && decl.name.text === name) {
88+
return true;
89+
}
90+
}
91+
}
92+
93+
return false;
94+
};
95+
}
96+
97+
/**
98+
* A recursive function that builds a tree of classes that extend from each other.
99+
*
100+
* @param compilerCtx the current compiler context
101+
* @param classDeclaration a class declaration to analyze
102+
* @param dependentClasses a flat array tree of classes that extend from each other
103+
* @param typeChecker the TypeScript type checker
104+
* @returns a flat array of classes that extend from each other, including the current class
105+
*/
106+
function buildExtendsTree(
107+
compilerCtx: d.CompilerCtx,
108+
classDeclaration: ts.ClassDeclaration,
109+
dependentClasses: { classNode: ts.ClassDeclaration; fileName: string }[],
110+
typeChecker: ts.TypeChecker,
111+
buildCtx: d.BuildCtx,
112+
) {
113+
const hasHeritageClauses = classDeclaration.heritageClauses;
114+
if (!hasHeritageClauses?.length) return dependentClasses;
115+
116+
const extendsClause = hasHeritageClauses.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword);
117+
if (!extendsClause) return dependentClasses;
118+
119+
let classIdentifiers: ts.Identifier[] = [];
120+
let foundClassDeclaration: ts.ClassDeclaration | undefined;
121+
// used when the class we found is wrapped in a mixin factory function -
122+
// the extender ctor will be from a dynamic function argument - so we stop recursing
123+
let keepLooking = true;
124+
125+
extendsClause.types.forEach((type) => {
126+
if (
127+
ts.isExpressionWithTypeArguments(type) &&
128+
ts.isCallExpression(type.expression) &&
129+
type.expression.expression.getText() === 'Mixin'
130+
) {
131+
// handle mixin case: extends Mixin(SomeClassFactoryFunction1, SomeClassFactoryFunction2)
132+
classIdentifiers = type.expression.arguments.filter(ts.isIdentifier);
133+
} else if (ts.isIdentifier(type.expression)) {
134+
// handle simple case: extends SomeClass
135+
classIdentifiers = [type.expression];
136+
}
137+
});
138+
139+
classIdentifiers.forEach((extendee) => {
140+
try {
141+
// happy path (normally 1 file level removed): the extends type resolves to a class declaration in another file
142+
143+
const symbol = typeChecker.getSymbolAtLocation(extendee);
144+
const aliasedSymbol = symbol ? typeChecker.getAliasedSymbol(symbol) : undefined;
145+
foundClassDeclaration = aliasedSymbol?.declarations?.find(ts.isClassDeclaration);
146+
147+
if (!foundClassDeclaration) {
148+
// the found `extends` type does not resolve to a class declaration;
149+
// if it's wrapped in a function - let's try and find it inside
150+
const node = aliasedSymbol?.declarations?.[0];
151+
foundClassDeclaration = findClassWalk(node);
152+
keepLooking = false;
153+
}
154+
155+
if (foundClassDeclaration && !dependentClasses.some((dc) => dc.classNode === foundClassDeclaration)) {
156+
const foundModule = compilerCtx.moduleMap.get(foundClassDeclaration.getSourceFile().fileName);
157+
158+
if (foundModule) {
159+
const source = foundModule.staticSourceFile as ts.SourceFile;
160+
const sourceClass = findClassWalk(source, foundClassDeclaration.name?.getText());
161+
162+
if (sourceClass) {
163+
dependentClasses.push({ classNode: sourceClass, fileName: source.fileName });
164+
if (keepLooking) {
165+
buildExtendsTree(compilerCtx, foundClassDeclaration, dependentClasses, typeChecker, buildCtx);
166+
}
167+
}
168+
}
169+
}
170+
} catch (_e) {
171+
// sad path (normally >1 levels removed): the extends type does not resolve so let's find it manually:
172+
173+
const currentSource = classDeclaration.getSourceFile();
174+
if (!currentSource) return;
175+
176+
// let's see if we can find the class in the current source file first
177+
const matchedStatement = currentSource.statements.find(matchesNamedDeclaration(extendee.getText()));
178+
179+
if (matchedStatement && ts.isClassDeclaration(matchedStatement)) {
180+
foundClassDeclaration = matchedStatement;
181+
} else if (matchedStatement) {
182+
// the found `extends` type does not resolve to a class declaration;
183+
// if it's wrapped in a function - let's try and find it inside
184+
foundClassDeclaration = findClassWalk(matchedStatement);
185+
keepLooking = false;
186+
}
187+
188+
if (foundClassDeclaration && !dependentClasses.some((dc) => dc.classNode === foundClassDeclaration)) {
189+
// we found the class declaration in the current module
190+
dependentClasses.push({ classNode: foundClassDeclaration, fileName: currentSource.fileName });
191+
if (keepLooking) {
192+
buildExtendsTree(compilerCtx, foundClassDeclaration, dependentClasses, typeChecker, buildCtx);
193+
}
194+
return;
195+
}
196+
197+
// if not found, let's check the import statements
198+
const importStatements = currentSource.statements.filter(ts.isImportDeclaration);
199+
importStatements.forEach((statement) => {
200+
// 1) loop through import declarations in the current source file
201+
if (statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause?.namedBindings)) {
202+
statement.importClause?.namedBindings.elements.forEach((element) => {
203+
// 2) loop through the named bindings of the import declaration
204+
205+
if (element.name.getText() === extendee.getText()) {
206+
// 3) check the name matches the `extends` type expression
207+
const className = element.propertyName?.getText() || element.name.getText();
208+
const foundFile = tsResolveModuleName(
209+
buildCtx.config,
210+
compilerCtx,
211+
statement.moduleSpecifier.getText().replaceAll(/['"]/g, ''),
212+
currentSource.fileName,
213+
);
214+
215+
if (foundFile?.resolvedModule && className) {
216+
// 4) resolve the module name to a file
217+
const foundModule = compilerCtx.moduleMap.get(foundFile.resolvedModule.resolvedFileName);
218+
219+
// 5) look for the corresponding resolved statement
220+
const matchedStatement = (foundModule?.staticSourceFile as ts.SourceFile).statements.find(
221+
matchesNamedDeclaration(className),
222+
);
223+
foundClassDeclaration = matchedStatement
224+
? ts.isClassDeclaration(matchedStatement)
225+
? matchedStatement
226+
: undefined
227+
: undefined;
228+
229+
if (!foundClassDeclaration && matchedStatement) {
230+
// 5.b) the found `extends` type does not resolve to a class declaration;
231+
// if it's wrapped in a function - let's try and find it inside
232+
foundClassDeclaration = findClassWalk(matchedStatement);
233+
keepLooking = false;
234+
}
235+
236+
if (foundClassDeclaration && !dependentClasses.some((dc) => dc.classNode === foundClassDeclaration)) {
237+
// 6) if we found the class declaration, push it and check if it itself extends from another class
238+
dependentClasses.push({ classNode: foundClassDeclaration, fileName: currentSource.fileName });
239+
if (keepLooking) {
240+
buildExtendsTree(compilerCtx, foundClassDeclaration, dependentClasses, typeChecker, buildCtx);
241+
}
242+
return;
243+
}
244+
}
245+
}
246+
});
247+
}
248+
});
249+
}
250+
});
251+
252+
return dependentClasses;
253+
}
254+
255+
/**
256+
* Given a class declaration, this function will analyze its heritage clauses
257+
* to find any extended classes, and then parse the static members of those
258+
* extended classes to merge them into the current class's metadata.
259+
*
260+
* @param compilerCtx
261+
* @param typeChecker
262+
* @param buildCtx
263+
* @param cmpNode
264+
* @param staticMembers
265+
* @returns an object containing merged metadata from extended classes
266+
*/
267+
export function mergeExtendedClassMeta(
268+
compilerCtx: d.CompilerCtx,
269+
typeChecker: ts.TypeChecker,
270+
buildCtx: d.BuildCtx,
271+
cmpNode: ts.ClassDeclaration,
272+
staticMembers: ts.ClassElement[],
273+
) {
274+
const tree = buildExtendsTree(compilerCtx, cmpNode, [], typeChecker, buildCtx);
275+
let hasMixin = false;
276+
let doesExtend = false;
277+
let properties = parseStaticProps(staticMembers);
278+
let states = parseStaticStates(staticMembers);
279+
let methods = parseStaticMethods(staticMembers);
280+
let listeners = parseStaticListeners(staticMembers);
281+
let events = parseStaticEvents(staticMembers);
282+
let watchers = parseStaticWatchers(staticMembers);
283+
let classMethods = cmpNode.members.filter(ts.isMethodDeclaration);
284+
285+
tree.forEach((extendedClass) => {
286+
const extendedStaticMembers = extendedClass.classNode.members.filter(isStaticGetter);
287+
const mixinProps = parseStaticProps(extendedStaticMembers) ?? [];
288+
const mixinStates = parseStaticStates(extendedStaticMembers) ?? [];
289+
const mixinMethods = parseStaticMethods(extendedStaticMembers) ?? [];
290+
const isMixin = mixinProps.length > 0 || mixinStates.length > 0;
291+
const module = compilerCtx.moduleMap.get(extendedClass.fileName);
292+
if (!module) return;
293+
294+
module.isMixin = isMixin;
295+
module.isExtended = true;
296+
doesExtend = true;
297+
298+
if (isMixin && !detectModernPropDeclarations(extendedClass.classNode)) {
299+
const err = buildWarn(buildCtx.diagnostics);
300+
const target = buildCtx.config.tsCompilerOptions?.target;
301+
err.messageText = `Component classes can only extend from other Stencil decorated base classes when targetting more modern JavaScript (ES2022 and above).
302+
${target ? `Your current TypeScript configuration is set to target \`${ts.ScriptTarget[target]}\`.` : ''} Please amend your tsconfig.json.`;
303+
if (!buildCtx.config._isTesting) augmentDiagnosticWithNode(err, extendedClass.classNode);
304+
}
305+
306+
properties = [...deDupeMembers(mixinProps, properties), ...properties];
307+
states = [...deDupeMembers(mixinStates, states), ...states];
308+
methods = [...deDupeMembers(mixinMethods, methods), ...methods];
309+
listeners = [...deDupeMembers(parseStaticListeners(extendedStaticMembers) ?? [], listeners), ...listeners];
310+
events = [...deDupeMembers(parseStaticEvents(extendedStaticMembers) ?? [], events), ...events];
311+
watchers = [...deDupeMembers(parseStaticWatchers(extendedStaticMembers) ?? [], watchers), ...watchers];
312+
classMethods = [...classMethods, ...(extendedClass.classNode.members.filter(ts.isMethodDeclaration) ?? [])];
313+
314+
if (isMixin) hasMixin = true;
315+
});
316+
317+
return { hasMixin, doesExtend, properties, states, methods, listeners, events, watchers, classMethods };
318+
}

0 commit comments

Comments
 (0)