Skip to content

Commit 26231e9

Browse files
committed
fix: hoist types related to $props rune if possible
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 02b6b06 commit 26231e9

File tree

16 files changed

+601
-143
lines changed

16 files changed

+601
-143
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: 131 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { internalHelpers } from '../../helpers';
44
import { surroundWithIgnoreComments } from '../../utils/ignore';
55
import { preprendStr, overwriteStr } from '../../utils/magic-string';
66
import { findExportKeyword, getLastLeadingDoc, isInterfaceOrTypeDeclaration } from '../utils/tsAst';
7+
import { HoistableInterfaces } from './HoistableInterfaces';
78

89
export function is$$PropsDeclaration(
910
node: ts.Node
@@ -21,6 +22,7 @@ interface ExportedName {
2122
}
2223

2324
export class ExportedNames {
25+
public hoistableInterfaces = new HoistableInterfaces();
2426
public usesAccessors = false;
2527
/**
2628
* Uses the `$$Props` type
@@ -35,7 +37,9 @@ export class ExportedNames {
3537
* If using TS, this returns the generic string, if using JS, returns the `@type {..}` string.
3638
*/
3739
private $props = {
40+
/** The JSDoc type; not set when TS type exists */
3841
comment: '',
42+
/** The TS type */
3943
type: '',
4044
bindings: [] as string[]
4145
};
@@ -173,10 +177,13 @@ export class ExportedNames {
173177
}
174178
}
175179

180+
// Easy mode: User uses TypeScript and typed the $props() rune
176181
if (node.initializer.typeArguments?.length > 0 || node.type) {
182+
this.hoistableInterfaces.analyze$propsRune(node);
183+
177184
const generic_arg = node.initializer.typeArguments?.[0] || node.type;
178185
const generic = generic_arg.getText();
179-
if (!generic.includes('{')) {
186+
if (ts.isTypeReferenceNode(generic_arg)) {
180187
this.$props.type = generic;
181188
} else {
182189
// Create a virtual type alias for the unnamed generic and reuse it for the props return type
@@ -199,145 +206,148 @@ export class ExportedNames {
199206
surroundWithIgnoreComments(this.$props.type)
200207
);
201208
}
202-
} else {
203-
if (!this.isTsFile) {
204-
const text = node.getSourceFile().getFullText();
205-
let start = -1;
206-
let comment: string;
207-
// reverse because we want to look at the last comment before the node first
208-
for (const c of [...(ts.getLeadingCommentRanges(text, node.pos) || [])].reverse()) {
209+
210+
return;
211+
}
212+
213+
// Hard mode: User uses JSDoc or didn't type the $props() rune
214+
if (!this.isTsFile) {
215+
const text = node.getSourceFile().getFullText();
216+
let start = -1;
217+
let comment: string;
218+
// reverse because we want to look at the last comment before the node first
219+
for (const c of [...(ts.getLeadingCommentRanges(text, node.pos) || [])].reverse()) {
220+
const potential_match = text.substring(c.pos, c.end);
221+
if (/@type\b/.test(potential_match)) {
222+
comment = potential_match;
223+
start = c.pos + this.astOffset;
224+
break;
225+
}
226+
}
227+
if (!comment) {
228+
for (const c of [
229+
...(ts.getLeadingCommentRanges(text, node.parent.pos) || []).reverse()
230+
]) {
209231
const potential_match = text.substring(c.pos, c.end);
210232
if (/@type\b/.test(potential_match)) {
211233
comment = potential_match;
212234
start = c.pos + this.astOffset;
213235
break;
214236
}
215237
}
216-
if (!comment) {
217-
for (const c of [
218-
...(ts.getLeadingCommentRanges(text, node.parent.pos) || []).reverse()
219-
]) {
220-
const potential_match = text.substring(c.pos, c.end);
221-
if (/@type\b/.test(potential_match)) {
222-
comment = potential_match;
223-
start = c.pos + this.astOffset;
224-
break;
225-
}
226-
}
227-
}
228-
229-
if (comment && /\/\*\*[^@]*?@type\s*{\s*{.*}\s*}\s*\*\//.test(comment)) {
230-
// Create a virtual type alias for the unnamed generic and reuse it for the props return type
231-
// so that rename, find references etc works seamlessly across components
232-
this.$props.comment = '/** @type {$$ComponentProps} */';
233-
const type_start = this.str.original.indexOf('@type', start);
234-
this.str.overwrite(type_start, type_start + 5, '@typedef');
235-
const end = this.str.original.indexOf('*/', start);
236-
this.str.overwrite(end, end + 2, ' $$ComponentProps */' + this.$props.comment);
237-
} else {
238-
// Complex comment or simple `@type {AType}` comment which we just use as-is.
239-
// For the former this means things like rename won't work properly across components.
240-
this.$props.comment = comment || '';
241-
}
242238
}
243239

244-
if (this.$props.comment) {
245-
return;
240+
if (comment && /\/\*\*[^@]*?@type\s*{\s*{.*}\s*}\s*\*\//.test(comment)) {
241+
// Create a virtual type alias for the unnamed generic and reuse it for the props return type
242+
// so that rename, find references etc works seamlessly across components
243+
this.$props.comment = '/** @type {$$ComponentProps} */';
244+
const type_start = this.str.original.indexOf('@type', start);
245+
this.str.overwrite(type_start, type_start + 5, '@typedef');
246+
const end = this.str.original.indexOf('*/', start);
247+
this.str.overwrite(end, end + 2, ' $$ComponentProps */' + this.$props.comment);
248+
} else {
249+
// Complex comment or simple `@type {AType}` comment which we just use as-is.
250+
// For the former this means things like rename won't work properly across components.
251+
this.$props.comment = comment || '';
246252
}
253+
}
247254

248-
// Do a best-effort to extract the props from the object literal
249-
let propsStr = '';
250-
let withUnknown = false;
251-
let props = [];
252-
253-
const isKitRouteFile = internalHelpers.isKitRouteFile(this.basename);
254-
const isKitLayoutFile = isKitRouteFile && this.basename.includes('layout');
255-
256-
if (ts.isObjectBindingPattern(node.name)) {
257-
for (const element of node.name.elements) {
258-
if (
259-
!ts.isIdentifier(element.name) ||
260-
(element.propertyName && !ts.isIdentifier(element.propertyName)) ||
261-
!!element.dotDotDotToken
262-
) {
263-
withUnknown = true;
264-
} else {
265-
const name = element.propertyName
266-
? (element.propertyName as ts.Identifier).text
267-
: element.name.text;
268-
if (isKitRouteFile) {
269-
if (name === 'data') {
270-
props.push(
271-
`data: import('./$types.js').${
272-
isKitLayoutFile ? 'LayoutData' : 'PageData'
273-
}`
274-
);
275-
}
276-
if (name === 'form' && !isKitLayoutFile) {
277-
props.push(`form: import('./$types.js').ActionData`);
278-
}
279-
} else if (element.initializer) {
280-
const type = ts.isAsExpression(element.initializer)
281-
? element.initializer.type.getText()
282-
: ts.isStringLiteral(element.initializer)
283-
? 'string'
284-
: ts.isNumericLiteral(element.initializer)
285-
? 'number'
286-
: element.initializer.kind === ts.SyntaxKind.TrueKeyword ||
287-
element.initializer.kind === ts.SyntaxKind.FalseKeyword
288-
? 'boolean'
289-
: ts.isIdentifier(element.initializer)
290-
? `typeof ${element.initializer.text}`
291-
: ts.isObjectLiteralExpression(element.initializer)
292-
? 'Record<string, unknown>'
293-
: ts.isArrayLiteralExpression(element.initializer)
294-
? 'unknown[]'
295-
: 'unknown';
296-
props.push(`${name}?: ${type}`);
297-
} else {
298-
props.push(`${name}: unknown`);
255+
if (this.$props.comment) {
256+
// User uses JsDoc
257+
return;
258+
}
259+
260+
// Do a best-effort to extract the props from the object literal
261+
let propsStr = '';
262+
let withUnknown = false;
263+
let props = [];
264+
265+
const isKitRouteFile = internalHelpers.isKitRouteFile(this.basename);
266+
const isKitLayoutFile = isKitRouteFile && this.basename.includes('layout');
267+
268+
if (ts.isObjectBindingPattern(node.name)) {
269+
for (const element of node.name.elements) {
270+
if (
271+
!ts.isIdentifier(element.name) ||
272+
(element.propertyName && !ts.isIdentifier(element.propertyName)) ||
273+
!!element.dotDotDotToken
274+
) {
275+
withUnknown = true;
276+
} else {
277+
const name = element.propertyName
278+
? (element.propertyName as ts.Identifier).text
279+
: element.name.text;
280+
if (isKitRouteFile) {
281+
if (name === 'data') {
282+
props.push(
283+
`data: import('./$types.js').${
284+
isKitLayoutFile ? 'LayoutData' : 'PageData'
285+
}`
286+
);
299287
}
288+
if (name === 'form' && !isKitLayoutFile) {
289+
props.push(`form: import('./$types.js').ActionData`);
290+
}
291+
} else if (element.initializer) {
292+
const type = ts.isAsExpression(element.initializer)
293+
? element.initializer.type.getText()
294+
: ts.isStringLiteral(element.initializer)
295+
? 'string'
296+
: ts.isNumericLiteral(element.initializer)
297+
? 'number'
298+
: element.initializer.kind === ts.SyntaxKind.TrueKeyword ||
299+
element.initializer.kind === ts.SyntaxKind.FalseKeyword
300+
? 'boolean'
301+
: ts.isIdentifier(element.initializer)
302+
? `typeof ${element.initializer.text}`
303+
: ts.isObjectLiteralExpression(element.initializer)
304+
? 'Record<string, unknown>'
305+
: ts.isArrayLiteralExpression(element.initializer)
306+
? 'unknown[]'
307+
: 'unknown';
308+
props.push(`${name}?: ${type}`);
309+
} else {
310+
props.push(`${name}: unknown`);
300311
}
301312
}
313+
}
302314

303-
if (isKitLayoutFile) {
304-
props.push(`children: import('svelte').Snippet`);
305-
}
315+
if (isKitLayoutFile) {
316+
props.push(`children: import('svelte').Snippet`);
317+
}
306318

307-
if (props.length > 0) {
308-
propsStr =
309-
`{ ${props.join(', ')} }` +
310-
(withUnknown ? ' & Record<string, unknown>' : '');
311-
} else if (withUnknown) {
312-
propsStr = 'Record<string, unknown>';
313-
} else {
314-
propsStr = 'Record<string, never>';
315-
}
316-
} else {
319+
if (props.length > 0) {
320+
propsStr =
321+
`{ ${props.join(', ')} }` + (withUnknown ? ' & Record<string, unknown>' : '');
322+
} else if (withUnknown) {
317323
propsStr = 'Record<string, unknown>';
324+
} else {
325+
propsStr = 'Record<string, never>';
318326
}
327+
} else {
328+
propsStr = 'Record<string, unknown>';
329+
}
319330

320-
// Create a virtual type alias for the unnamed generic and reuse it for the props return type
321-
// so that rename, find references etc works seamlessly across components
322-
if (this.isTsFile) {
323-
this.$props.type = '$$ComponentProps';
324-
if (props.length > 0 || withUnknown) {
325-
preprendStr(
326-
this.str,
327-
node.parent.pos + this.astOffset,
328-
surroundWithIgnoreComments(`;type $$ComponentProps = ${propsStr};`)
329-
);
330-
preprendStr(this.str, node.name.end + this.astOffset, `: ${this.$props.type}`);
331-
}
332-
} else {
333-
this.$props.comment = '/** @type {$$ComponentProps} */';
334-
if (props.length > 0 || withUnknown) {
335-
preprendStr(
336-
this.str,
337-
node.pos + this.astOffset,
338-
`/** @typedef {${propsStr}} $$ComponentProps */${this.$props.comment}`
339-
);
340-
}
331+
// Create a virtual type alias for the unnamed generic and reuse it for the props return type
332+
// so that rename, find references etc works seamlessly across components
333+
if (this.isTsFile) {
334+
this.$props.type = '$$ComponentProps';
335+
if (props.length > 0 || withUnknown) {
336+
preprendStr(
337+
this.str,
338+
node.parent.pos + this.astOffset,
339+
surroundWithIgnoreComments(`;type $$ComponentProps = ${propsStr};`)
340+
);
341+
preprendStr(this.str, node.name.end + this.astOffset, `: ${this.$props.type}`);
342+
}
343+
} else {
344+
this.$props.comment = '/** @type {$$ComponentProps} */';
345+
if (props.length > 0 || withUnknown) {
346+
preprendStr(
347+
this.str,
348+
node.pos + this.astOffset,
349+
`/** @typedef {${propsStr}} $$ComponentProps */${this.$props.comment}`
350+
);
341351
}
342352
}
343353
}

0 commit comments

Comments
 (0)