Skip to content

Commit 2478212

Browse files
authored
feat: Svelte 5 component class/function interop (#2380)
Svelte 5 uses functions to define components under the hood. This should be represented in the types. We can't just switch to using functions though because d.ts files created from Svelte 4 libraries should still work, and those contain classes. So we need interop between functions and classes. The idea is therefore: Svelte 5 creates a default export which is both a function and a class constructor Various places are adjusted to support the new default exports Also see sveltejs/svelte#11775
1 parent 15a4aab commit 2478212

File tree

130 files changed

+1565
-697
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

130 files changed

+1565
-697
lines changed

packages/language-server/src/plugins/typescript/ComponentInfoProvider.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,12 @@ export class JsOrTsComponentInfoProvider implements ComponentInfoProvider {
9595
return null;
9696
}
9797

98-
const defClass = findContainingNode(sourceFile, def.textSpan, ts.isClassDeclaration);
98+
const defClass = findContainingNode(
99+
sourceFile,
100+
def.textSpan,
101+
(node): node is ts.ClassDeclaration | ts.VariableDeclaration =>
102+
ts.isClassDeclaration(node) || ts.isTypeAliasDeclaration(node)
103+
);
99104

100105
if (!defClass) {
101106
return null;

packages/language-server/src/plugins/typescript/DocumentSnapshot.ts

+1-4
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ export class SvelteDocumentSnapshot implements DocumentSnapshot {
268268
private url = pathToUrl(this.filePath);
269269

270270
version = this.parent.version;
271+
isSvelte5Plus = Number(this.svelteVersion?.split('.')[0]) >= 5;
271272

272273
constructor(
273274
public readonly parent: Document,
@@ -281,10 +282,6 @@ export class SvelteDocumentSnapshot implements DocumentSnapshot {
281282
private readonly htmlAst?: TemplateNode
282283
) {}
283284

284-
get isSvelte5Plus() {
285-
return Number(this.svelteVersion?.split('.')[0]) >= 5;
286-
}
287-
288285
get filePath() {
289286
return this.parent.getFilePath() || '';
290287
}

packages/language-server/src/plugins/typescript/features/CompletionProvider.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -851,7 +851,7 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionRe
851851

852852
if (detail) {
853853
const { detail: itemDetail, documentation: itemDocumentation } =
854-
this.getCompletionDocument(detail, is$typeImport);
854+
this.getCompletionDocument(tsDoc, detail, is$typeImport);
855855

856856
// VSCode + tsserver won't have this pop-in effect
857857
// because tsserver has internal APIs for caching
@@ -897,7 +897,11 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionRe
897897
return completionItem;
898898
}
899899

900-
private getCompletionDocument(compDetail: ts.CompletionEntryDetails, is$typeImport: boolean) {
900+
private getCompletionDocument(
901+
tsDoc: SvelteDocumentSnapshot,
902+
compDetail: ts.CompletionEntryDetails,
903+
is$typeImport: boolean
904+
) {
901905
const { sourceDisplay, documentation: tsDocumentation, displayParts, tags } = compDetail;
902906
let parts = compDetail.codeActions?.map((codeAction) => codeAction.description) ?? [];
903907

@@ -910,7 +914,18 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionRe
910914
);
911915
}
912916

913-
parts.push(changeSvelteComponentName(ts.displayPartsToString(displayParts)));
917+
let text = changeSvelteComponentName(ts.displayPartsToString(displayParts));
918+
if (tsDoc.isSvelte5Plus && text.includes('(alias)')) {
919+
// The info contains both the const and type export along with a bunch of gibberish we want to hide
920+
if (text.includes('__SvelteComponent_')) {
921+
// import - remove completely
922+
text = '';
923+
} else if (text.includes('__sveltets_2_IsomorphicComponent')) {
924+
// already imported - only keep the last part
925+
text = text.substring(text.lastIndexOf('import'));
926+
}
927+
}
928+
parts.push(text);
914929

915930
const markdownDoc = getMarkdownDocumentation(tsDocumentation, tags);
916931
const documentation: MarkupContent | undefined = markdownDoc

packages/language-server/src/plugins/typescript/features/DiagnosticsProvider.ts

+7-5
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export class DiagnosticsProviderImpl implements DiagnosticsProvider {
167167
continue;
168168
}
169169

170-
diagnostic = adjustIfNecessary(diagnostic);
170+
diagnostic = adjustIfNecessary(diagnostic, tsDoc.isSvelte5Plus);
171171
diagnostic = swapDiagRangeStartEndIfNecessary(diagnostic);
172172
converted.push(diagnostic);
173173
}
@@ -350,7 +350,7 @@ function isNoUsedBeforeAssigned(
350350
/**
351351
* Some diagnostics have JSX-specific or confusing nomenclature. Enhance/adjust them for more clarity.
352352
*/
353-
function adjustIfNecessary(diagnostic: Diagnostic): Diagnostic {
353+
function adjustIfNecessary(diagnostic: Diagnostic, isSvelte5Plus: boolean): Diagnostic {
354354
if (
355355
diagnostic.code === DiagnosticCode.ARG_TYPE_X_NOT_ASSIGNABLE_TO_TYPE_Y &&
356356
diagnostic.message.includes('ConstructorOfATypedSvelteComponent')
@@ -362,9 +362,11 @@ function adjustIfNecessary(diagnostic: Diagnostic): Diagnostic {
362362
'\n\nPossible causes:\n' +
363363
'- You use the instance type of a component where you should use the constructor type\n' +
364364
'- Type definitions are missing for this Svelte Component. ' +
365-
'If you are using Svelte 3.31+, use SvelteComponentTyped to add a definition:\n' +
366-
' import type { SvelteComponentTyped } from "svelte";\n' +
367-
' class ComponentName extends SvelteComponentTyped<{propertyName: string;}> {}'
365+
(isSvelte5Plus
366+
? ''
367+
: 'If you are using Svelte 3.31+, use SvelteComponentTyped to add a definition:\n' +
368+
' import type { SvelteComponentTyped } from "svelte";\n' +
369+
' class ComponentName extends SvelteComponentTyped<{propertyName: string;}> {}')
368370
};
369371
}
370372

packages/language-server/src/plugins/typescript/features/FindReferencesProvider.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,26 @@ export class FindReferencesProviderImpl implements FindReferencesProvider {
168168
)
169169
);
170170

171-
return flatten(references.filter(isNotNullOrUndefined));
171+
const flattened: Location[] = [];
172+
for (const ref of references) {
173+
if (ref) {
174+
const tmp: Location[] = []; // perf optimization: we know each iteration has unique references
175+
for (const r of ref) {
176+
const exists = flattened.some(
177+
(f) =>
178+
f.uri === r.uri &&
179+
f.range.start.line === r.range.start.line &&
180+
f.range.start.character === r.range.start.character
181+
);
182+
if (!exists) {
183+
tmp.push(r);
184+
}
185+
}
186+
flattened.push(...tmp);
187+
}
188+
}
189+
190+
return flattened;
172191
}
173192

174193
private async mapReference(

packages/language-server/src/plugins/typescript/features/HoverProvider.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,16 @@ export class HoverProviderImpl implements HoverProvider {
2525
return null;
2626
}
2727

28-
const declaration = ts.displayPartsToString(info.displayParts);
28+
let declaration = ts.displayPartsToString(info.displayParts);
29+
if (
30+
tsDoc.isSvelte5Plus &&
31+
declaration.includes('(alias)') &&
32+
declaration.includes('__sveltets_2_IsomorphicComponent')
33+
) {
34+
// info ends with "import ComponentName"
35+
declaration = declaration.substring(declaration.lastIndexOf('import'));
36+
}
37+
2938
const documentation = getMarkdownDocumentation(info.documentation, info.tags);
3039

3140
// https://microsoft.github.io/language-server-protocol/specification#textDocument_hover

packages/language-server/src/plugins/typescript/features/utils.ts

+29-8
Original file line numberDiff line numberDiff line change
@@ -50,21 +50,42 @@ export function getComponentAtPosition(
5050
doc.positionAt(node.start + symbolPosWithinNode + 1)
5151
);
5252

53-
let def = lang.getDefinitionAtPosition(tsDoc.filePath, tsDoc.offsetAt(generatedPosition))?.[0];
54-
55-
while (def != null && def.kind !== ts.ScriptElementKind.classElement) {
56-
const newDef = lang.getDefinitionAtPosition(tsDoc.filePath, def.textSpan.start)?.[0];
57-
if (newDef?.fileName === def.fileName && newDef?.textSpan.start === def.textSpan.start) {
53+
let defs = lang.getDefinitionAtPosition(tsDoc.filePath, tsDoc.offsetAt(generatedPosition));
54+
// Svelte 5 uses a const and a type alias instead of a class, and we want the latter.
55+
// We still gotta check for a class in Svelte 5 because of d.ts files generated for Svelte 4 containing classes.
56+
let def1 = defs?.[0];
57+
let def2 = tsDoc.isSvelte5Plus ? defs?.[1] : undefined;
58+
59+
while (
60+
def1 != null &&
61+
def1.kind !== ts.ScriptElementKind.classElement &&
62+
(def2 == null ||
63+
def2.kind !== ts.ScriptElementKind.constElement ||
64+
!def2.name.endsWith('__SvelteComponent_'))
65+
) {
66+
const newDefs = lang.getDefinitionAtPosition(tsDoc.filePath, def1.textSpan.start);
67+
const newDef = newDefs?.[0];
68+
if (newDef?.fileName === def1.fileName && newDef?.textSpan.start === def1.textSpan.start) {
5869
break;
5970
}
60-
def = newDef;
71+
defs = newDefs;
72+
def1 = newDef;
73+
def2 = tsDoc.isSvelte5Plus ? newDefs?.[1] : undefined;
6174
}
6275

63-
if (!def) {
76+
if (!def1 && !def2) {
6477
return null;
6578
}
6679

67-
return JsOrTsComponentInfoProvider.create(lang, def);
80+
if (
81+
def2 != null &&
82+
def2.kind === ts.ScriptElementKind.constElement &&
83+
def2.name.endsWith('__SvelteComponent_')
84+
) {
85+
def1 = undefined;
86+
}
87+
88+
return JsOrTsComponentInfoProvider.create(lang, def1! || def2!);
6889
}
6990

7091
export function isComponentAtPosition(

packages/language-server/test/plugins/typescript/features/CallHierarchyProvider.test.ts

+12
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import { LSAndTSDocResolver } from '../../../../src/plugins/typescript/LSAndTSDo
1414
import { __resetCache } from '../../../../src/plugins/typescript/service';
1515
import { pathToUrl } from '../../../../src/utils';
1616
import { serviceWarmup } from '../test-utils';
17+
import { VERSION } from 'svelte/compiler';
1718

1819
const testDir = path.join(__dirname, '..');
20+
const isSvelte5Plus = +VERSION.split('.')[0] >= 5;
1921

2022
describe('CallHierarchyProvider', function () {
2123
const callHierarchyTestDirRelative = path.join('testfiles', 'call-hierarchy');
@@ -386,6 +388,11 @@ describe('CallHierarchyProvider', function () {
386388
});
387389

388390
it('can provide outgoing calls for component file', async () => {
391+
if (isSvelte5Plus) {
392+
// Doesn't work due to https://github.com/microsoft/TypeScript/issues/43740 and https://github.com/microsoft/TypeScript/issues/42375
393+
return;
394+
}
395+
389396
const { provider, document } = setup(outgoingComponentName);
390397

391398
const items = await provider.prepareCallHierarchy(document, { line: 10, character: 1 });
@@ -411,6 +418,11 @@ describe('CallHierarchyProvider', function () {
411418
});
412419

413420
it('can provide outgoing calls for component tags', async () => {
421+
if (isSvelte5Plus) {
422+
// Doesn't work due to https://github.com/microsoft/TypeScript/issues/43740 and https://github.com/microsoft/TypeScript/issues/42375
423+
return;
424+
}
425+
414426
const { provider, document } = setup(outgoingComponentName);
415427

416428
const items = await provider.prepareCallHierarchy(document, { line: 0, character: 2 });

packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts

+13
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ import { __resetCache } from '../../../../src/plugins/typescript/service';
2121
import { pathToUrl } from '../../../../src/utils';
2222
import { recursiveServiceWarmup } from '../test-utils';
2323
import { DiagnosticCode } from '../../../../src/plugins/typescript/features/DiagnosticsProvider';
24+
import { VERSION } from 'svelte/compiler';
2425

2526
const testDir = path.join(__dirname, '..');
2627
const indent = ' '.repeat(4);
28+
const isSvelte5Plus = +VERSION.split('.')[0] >= 5;
2729

2830
describe('CodeActionsProvider', function () {
2931
recursiveServiceWarmup(
@@ -374,6 +376,17 @@ describe('CodeActionsProvider', function () {
374376
uri: getUri('codeaction-checkJs.svelte'),
375377
version: null
376378
};
379+
380+
if (isSvelte5Plus) {
381+
// Maybe because of the hidden interface declarations? It's harmless anyway
382+
if (
383+
codeActions.length === 4 &&
384+
codeActions[3].title === "Add '@ts-ignore' to all error messages"
385+
) {
386+
codeActions.splice(3, 1);
387+
}
388+
}
389+
377390
assert.deepStrictEqual(codeActions, <CodeAction[]>[
378391
{
379392
edit: {

packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts

+12-4
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@ import { sortBy } from 'lodash';
2424
import { LSConfigManager } from '../../../../src/ls-config';
2525
import { __resetCache } from '../../../../src/plugins/typescript/service';
2626
import { getRandomVirtualDirPath, serviceWarmup, setupVirtualEnvironment } from '../test-utils';
27+
import { VERSION } from 'svelte/compiler';
2728

2829
const testDir = join(__dirname, '..');
2930
const testFilesDir = join(testDir, 'testfiles', 'completions');
3031
const newLine = ts.sys.newLine;
3132
const indent = ' '.repeat(4);
33+
const isSvelte5Plus = +VERSION.split('.')[0] >= 5;
3234

3335
const fileNameToAbsoluteUri = (file: string) => {
3436
return pathToUrl(join(testFilesDir, file));
@@ -855,7 +857,7 @@ describe('CompletionProviderImpl', function () {
855857

856858
assert.strictEqual(
857859
detail,
858-
'Add import from "../imported-file.svelte"\n\nclass ImportedFile'
860+
`Add import from "../imported-file.svelte"${isSvelte5Plus ? '' : '\n\nclass ImportedFile'}`
859861
);
860862

861863
assert.strictEqual(
@@ -893,7 +895,7 @@ describe('CompletionProviderImpl', function () {
893895

894896
assert.strictEqual(
895897
detail,
896-
'Add import from "../imported-file.svelte"\n\nclass ImportedFile'
898+
`Add import from "../imported-file.svelte"${isSvelte5Plus ? '' : '\n\nclass ImportedFile'}`
897899
);
898900

899901
assert.strictEqual(
@@ -1502,7 +1504,10 @@ describe('CompletionProviderImpl', function () {
15021504
const item2 = completions2?.items.find((item) => item.label === 'Bar');
15031505
const { detail } = await completionProvider.resolveCompletion(document, item2!);
15041506

1505-
assert.strictEqual(detail, 'Add import from "./Bar.svelte"\n\nclass Bar');
1507+
assert.strictEqual(
1508+
detail,
1509+
`Add import from "./Bar.svelte"${isSvelte5Plus ? '' : '\n\nclass Bar'}`
1510+
);
15061511
});
15071512

15081513
it("doesn't use empty cache", async () => {
@@ -1551,7 +1556,10 @@ describe('CompletionProviderImpl', function () {
15511556
const item2 = completions?.items.find((item) => item.label === 'Bar');
15521557
const { detail } = await completionProvider.resolveCompletion(document, item2!);
15531558

1554-
assert.strictEqual(detail, 'Add import from "./Bar.svelte"\n\nclass Bar');
1559+
assert.strictEqual(
1560+
detail,
1561+
`Add import from "./Bar.svelte"${isSvelte5Plus ? '' : '\n\nclass Bar'}`
1562+
);
15551563
});
15561564

15571565
it('can auto import new export', async () => {

packages/language-server/test/plugins/typescript/features/FindComponentReferencesProvider.test.ts

+21-15
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ import { FindComponentReferencesProviderImpl } from '../../../../src/plugins/typ
77
import { LSAndTSDocResolver } from '../../../../src/plugins/typescript/LSAndTSDocResolver';
88
import { pathToUrl } from '../../../../src/utils';
99
import { serviceWarmup } from '../test-utils';
10+
import { Location } from 'vscode-html-languageservice';
11+
import { VERSION } from 'svelte/compiler';
1012

1113
const testDir = path.join(__dirname, '..', 'testfiles');
14+
const isSvelte5Plus = +VERSION.split('.')[0] >= 5;
1215

1316
describe('FindComponentReferencesProvider', function () {
1417
serviceWarmup(this, testDir);
@@ -54,20 +57,7 @@ describe('FindComponentReferencesProvider', function () {
5457

5558
const results = await provider.findComponentReferences(document.uri.toString());
5659

57-
assert.deepStrictEqual(results, [
58-
{
59-
range: {
60-
start: {
61-
line: 8,
62-
character: 15
63-
},
64-
end: {
65-
line: 8,
66-
character: 22
67-
}
68-
},
69-
uri: getUri('find-component-references-parent.svelte')
70-
},
60+
const expected: Location[] = [
7161
{
7262
range: {
7363
start: {
@@ -120,6 +110,22 @@ describe('FindComponentReferencesProvider', function () {
120110
},
121111
uri: getUri('find-component-references-parent2.svelte')
122112
}
123-
]);
113+
];
114+
if (!isSvelte5Plus) {
115+
expected.unshift({
116+
range: {
117+
start: {
118+
line: 8,
119+
character: 15
120+
},
121+
end: {
122+
line: 8,
123+
character: 22
124+
}
125+
},
126+
uri: getUri('find-component-references-parent.svelte')
127+
});
128+
}
129+
assert.deepStrictEqual(results, expected);
124130
});
125131
});

0 commit comments

Comments
 (0)