Skip to content

Commit bf2e459

Browse files
authored
fix: hoist types related to $props rune if possible (#2571)
This allows TypeScript to resolve the type more easily, especialy when in dts mode. The advantage is that now the type would be preserved as written, whereas without it the type would be inlined/infered, i.e. the interface that declares the props would not be kept
1 parent b83b665 commit bf2e459

File tree

23 files changed

+713
-155
lines changed

23 files changed

+713
-155
lines changed

packages/svelte2tsx/src/svelte2tsx/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { SlotHandler } from './nodes/slot';
1616
import { Stores } from './nodes/Stores';
1717
import TemplateScope from './nodes/TemplateScope';
1818
import { processInstanceScriptContent } from './processInstanceScriptContent';
19-
import { processModuleScriptTag } from './processModuleScriptTag';
19+
import { createModuleAst, ModuleAst, processModuleScriptTag } from './processModuleScriptTag';
2020
import { ScopeStack } from './utils/Scope';
2121
import { Generics } from './nodes/Generics';
2222
import { addComponentExport } from './addComponentExport';
@@ -362,7 +362,11 @@ export function svelte2tsx(
362362
*/
363363
let instanceScriptTarget = 0;
364364

365+
let moduleAst: ModuleAst | undefined;
366+
365367
if (moduleScriptTag) {
368+
moduleAst = createModuleAst(str, moduleScriptTag);
369+
366370
if (moduleScriptTag.start != 0) {
367371
//move our module tag to the top
368372
str.move(moduleScriptTag.start, moduleScriptTag.end, 0);
@@ -398,7 +402,7 @@ export function svelte2tsx(
398402
events,
399403
implicitStoreValues,
400404
options.mode,
401-
/**hasModuleScripts */ !!moduleScriptTag,
405+
moduleAst,
402406
options?.isTsFile,
403407
basename,
404408
svelte5Plus,
@@ -443,7 +447,8 @@ export function svelte2tsx(
443447
implicitStoreValues.getAccessedStores(),
444448
renderFunctionStart,
445449
scriptTag || options.mode === 'ts' ? undefined : (input) => `</>;${input}<>`
446-
)
450+
),
451+
moduleAst
447452
);
448453
}
449454

packages/svelte2tsx/src/svelte2tsx/nodes/ExportedNames.ts

Lines changed: 144 additions & 131 deletions
Large diffs are not rendered by default.
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
import ts from 'typescript';
2+
import MagicString from 'magic-string';
3+
4+
/**
5+
* Collects all imports and module-level declarations to then find out which interfaces/types are hoistable.
6+
*/
7+
export class HoistableInterfaces {
8+
private import_value_set: Set<string> = new Set();
9+
private import_type_set: Set<string> = new Set();
10+
private interface_map: Map<
11+
string,
12+
{ type_deps: Set<string>; value_deps: Set<string>; node: ts.Node }
13+
> = new Map();
14+
private props_interface = {
15+
name: '',
16+
node: null as ts.Node | null,
17+
type_deps: new Set<string>(),
18+
value_deps: new Set<string>()
19+
};
20+
21+
analyzeModuleScriptNode(node: ts.Node) {
22+
// Handle Import Declarations
23+
if (ts.isImportDeclaration(node) && node.importClause) {
24+
const is_type_only = node.importClause.isTypeOnly;
25+
26+
if (
27+
node.importClause.namedBindings &&
28+
ts.isNamedImports(node.importClause.namedBindings)
29+
) {
30+
node.importClause.namedBindings.elements.forEach((element) => {
31+
const import_name = element.name.text;
32+
if (is_type_only || element.isTypeOnly) {
33+
this.import_type_set.add(import_name);
34+
} else {
35+
this.import_value_set.add(import_name);
36+
}
37+
});
38+
}
39+
40+
// Handle default imports
41+
if (node.importClause.name) {
42+
const default_import = node.importClause.name.text;
43+
if (is_type_only) {
44+
this.import_type_set.add(default_import);
45+
} else {
46+
this.import_value_set.add(default_import);
47+
}
48+
}
49+
50+
// Handle namespace imports
51+
if (
52+
node.importClause.namedBindings &&
53+
ts.isNamespaceImport(node.importClause.namedBindings)
54+
) {
55+
const namespace_import = node.importClause.namedBindings.name.text;
56+
if (is_type_only) {
57+
this.import_type_set.add(namespace_import);
58+
} else {
59+
this.import_value_set.add(namespace_import);
60+
}
61+
}
62+
}
63+
64+
// Handle top-level declarations
65+
if (ts.isVariableStatement(node)) {
66+
node.declarationList.declarations.forEach((declaration) => {
67+
if (ts.isIdentifier(declaration.name)) {
68+
this.import_value_set.add(declaration.name.text);
69+
}
70+
});
71+
}
72+
73+
if (ts.isFunctionDeclaration(node) && node.name) {
74+
this.import_value_set.add(node.name.text);
75+
}
76+
77+
if (ts.isClassDeclaration(node) && node.name) {
78+
this.import_value_set.add(node.name.text);
79+
}
80+
81+
if (ts.isEnumDeclaration(node)) {
82+
this.import_value_set.add(node.name.text);
83+
}
84+
85+
if (ts.isTypeAliasDeclaration(node)) {
86+
this.import_type_set.add(node.name.text);
87+
}
88+
89+
if (ts.isInterfaceDeclaration(node)) {
90+
this.import_type_set.add(node.name.text);
91+
}
92+
}
93+
94+
analyzeInstanceScriptNode(node: ts.Node) {
95+
// Handle Import Declarations
96+
if (ts.isImportDeclaration(node) && node.importClause) {
97+
const is_type_only = node.importClause.isTypeOnly;
98+
99+
if (
100+
node.importClause.namedBindings &&
101+
ts.isNamedImports(node.importClause.namedBindings)
102+
) {
103+
node.importClause.namedBindings.elements.forEach((element) => {
104+
const import_name = element.name.text;
105+
if (is_type_only) {
106+
this.import_type_set.add(import_name);
107+
} else {
108+
this.import_value_set.add(import_name);
109+
}
110+
});
111+
}
112+
113+
// Handle default imports
114+
if (node.importClause.name) {
115+
const default_import = node.importClause.name.text;
116+
if (is_type_only) {
117+
this.import_type_set.add(default_import);
118+
} else {
119+
this.import_value_set.add(default_import);
120+
}
121+
}
122+
123+
// Handle namespace imports
124+
if (
125+
node.importClause.namedBindings &&
126+
ts.isNamespaceImport(node.importClause.namedBindings)
127+
) {
128+
const namespace_import = node.importClause.namedBindings.name.text;
129+
if (is_type_only) {
130+
this.import_type_set.add(namespace_import);
131+
} else {
132+
this.import_value_set.add(namespace_import);
133+
}
134+
}
135+
}
136+
137+
// Handle Interface Declarations
138+
if (ts.isInterfaceDeclaration(node)) {
139+
const interface_name = node.name.text;
140+
const type_dependencies: Set<string> = new Set();
141+
const value_dependencies: Set<string> = new Set();
142+
const generics = node.typeParameters?.map((param) => param.name.text) ?? [];
143+
144+
node.members.forEach((member) => {
145+
if (ts.isPropertySignature(member) && member.type) {
146+
this.collectTypeDependencies(
147+
member.type,
148+
type_dependencies,
149+
value_dependencies,
150+
generics
151+
);
152+
} else if (ts.isIndexSignatureDeclaration(member)) {
153+
this.collectTypeDependencies(
154+
member.type,
155+
type_dependencies,
156+
value_dependencies,
157+
generics
158+
);
159+
member.parameters.forEach((param) => {
160+
this.collectTypeDependencies(
161+
param.type,
162+
type_dependencies,
163+
value_dependencies,
164+
generics
165+
);
166+
});
167+
}
168+
});
169+
170+
this.interface_map.set(interface_name, {
171+
type_deps: type_dependencies,
172+
value_deps: value_dependencies,
173+
node
174+
});
175+
}
176+
177+
// Handle Type Alias Declarations
178+
if (ts.isTypeAliasDeclaration(node)) {
179+
const alias_name = node.name.text;
180+
const type_dependencies: Set<string> = new Set();
181+
const value_dependencies: Set<string> = new Set();
182+
const generics = node.typeParameters?.map((param) => param.name.text) ?? [];
183+
184+
this.collectTypeDependencies(
185+
node.type,
186+
type_dependencies,
187+
value_dependencies,
188+
generics
189+
);
190+
191+
this.interface_map.set(alias_name, {
192+
type_deps: type_dependencies,
193+
value_deps: value_dependencies,
194+
node
195+
});
196+
}
197+
}
198+
199+
analyze$propsRune(
200+
node: ts.VariableDeclaration & {
201+
initializer: ts.CallExpression & { expression: ts.Identifier };
202+
}
203+
) {
204+
if (node.initializer.typeArguments?.length > 0 || node.type) {
205+
const generic_arg = node.initializer.typeArguments?.[0] || node.type;
206+
if (ts.isTypeReferenceNode(generic_arg)) {
207+
const name = this.getEntityNameText(generic_arg.typeName);
208+
const interface_node = this.interface_map.get(name);
209+
if (interface_node) {
210+
this.props_interface.name = name;
211+
this.props_interface.type_deps = interface_node.type_deps;
212+
this.props_interface.value_deps = interface_node.value_deps;
213+
}
214+
} else {
215+
this.props_interface.name = '$$ComponentProps';
216+
this.props_interface.node = generic_arg;
217+
this.collectTypeDependencies(
218+
generic_arg,
219+
this.props_interface.type_deps,
220+
this.props_interface.value_deps,
221+
[]
222+
);
223+
}
224+
}
225+
}
226+
227+
/**
228+
* Traverses the AST to collect import statements and top-level interfaces,
229+
* then determines which interfaces can be hoisted.
230+
* @param source_file The TypeScript source file to analyze.
231+
* @returns An object containing sets of value imports, type imports, and hoistable interfaces.
232+
*/
233+
private determineHoistableInterfaces() {
234+
const hoistable_interfaces: Map<string, ts.Node> = new Map();
235+
let progress = true;
236+
237+
while (progress) {
238+
progress = false;
239+
240+
for (const [interface_name, deps] of this.interface_map.entries()) {
241+
if (hoistable_interfaces.has(interface_name)) {
242+
continue;
243+
}
244+
245+
const can_hoist = [...deps.type_deps, ...deps.value_deps].every((dep) => {
246+
return (
247+
this.import_type_set.has(dep) ||
248+
this.import_value_set.has(dep) ||
249+
hoistable_interfaces.has(dep)
250+
);
251+
});
252+
253+
if (can_hoist) {
254+
hoistable_interfaces.set(interface_name, deps.node);
255+
progress = true;
256+
}
257+
}
258+
}
259+
260+
if (this.props_interface.name === '$$ComponentProps') {
261+
const can_hoist = [
262+
...this.props_interface.type_deps,
263+
...this.props_interface.value_deps
264+
].every((dep) => {
265+
return (
266+
this.import_type_set.has(dep) ||
267+
this.import_value_set.has(dep) ||
268+
hoistable_interfaces.has(dep)
269+
);
270+
});
271+
272+
if (can_hoist) {
273+
hoistable_interfaces.set(this.props_interface.name, this.props_interface.node);
274+
}
275+
}
276+
277+
return hoistable_interfaces;
278+
}
279+
280+
/**
281+
* Moves all interfaces that can be hoisted to the top of the script, if the $props rune's type is hoistable.
282+
*/
283+
moveHoistableInterfaces(str: MagicString, astOffset: number, scriptStart: number) {
284+
if (!this.props_interface.name) return;
285+
286+
const hoistable = this.determineHoistableInterfaces();
287+
if (hoistable.has(this.props_interface.name)) {
288+
for (const [, node] of hoistable) {
289+
str.move(node.pos + astOffset, node.end + astOffset, scriptStart);
290+
}
291+
}
292+
}
293+
294+
/**
295+
* Collects type and value dependencies from a given TypeNode.
296+
* @param type_node The TypeNode to analyze.
297+
* @param type_dependencies The set to collect type dependencies into.
298+
* @param value_dependencies The set to collect value dependencies into.
299+
*/
300+
private collectTypeDependencies(
301+
type_node: ts.TypeNode,
302+
type_dependencies: Set<string>,
303+
value_dependencies: Set<string>,
304+
generics: string[]
305+
) {
306+
const walk = (node: ts.Node) => {
307+
if (ts.isTypeReferenceNode(node)) {
308+
const type_name = this.getEntityNameText(node.typeName);
309+
if (!generics.includes(type_name)) {
310+
type_dependencies.add(type_name);
311+
}
312+
} else if (ts.isTypeQueryNode(node)) {
313+
// Handle 'typeof' expressions: e.g., foo: typeof bar
314+
value_dependencies.add(this.getEntityNameText(node.exprName));
315+
}
316+
317+
ts.forEachChild(node, walk);
318+
};
319+
320+
walk(type_node);
321+
}
322+
323+
/**
324+
* Retrieves the full text of an EntityName (handles nested names).
325+
* @param entity_name The EntityName to extract text from.
326+
* @returns The full name as a string.
327+
*/
328+
private getEntityNameText(entity_name: ts.EntityName): string {
329+
if (ts.isIdentifier(entity_name)) {
330+
return entity_name.text;
331+
} else {
332+
return this.getEntityNameText(entity_name.left) + '.' + entity_name.right.text;
333+
}
334+
}
335+
}

0 commit comments

Comments
 (0)