Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/spotty-birds-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/svelte': patch
---

Using a Svelte component with generic type parameters now correctly infer props in .astro files
62 changes: 61 additions & 1 deletion packages/integrations/svelte/src/editor.cts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { svelte2tsx } from 'svelte2tsx';

const genericNameRE = /^(?:const |in |out )*(\w+)/;

export function toTSX(code: string, className: string): string {
let result = `
let ${className}__AstroComponent_: Error
Expand All @@ -12,9 +14,14 @@ export function toTSX(code: string, className: string): string {

// New svelte2tsx output (Svelte 5)
if (tsx.includes('export default $$Component;')) {
const generics = extractGenerics(tsx);
const genericSuffix = generics ? `<${generics.params}>` : '';
const innerType = generics
? `ReturnType<__sveltets_Render<${generics.names}>['props']>`
: `import('svelte').ComponentProps<typeof $$$$Component>`;
result = tsx.replace(
'export default $$Component;',
`export default function ${className}__AstroComponent_(_props: import('@astrojs/svelte/svelte-shims.d.ts').PropsWithClientDirectives<import('svelte').ComponentProps<typeof $$$$Component>>): any {}`,
`export default function ${className}__AstroComponent_${genericSuffix}(_props: import('@astrojs/svelte/svelte-shims.d.ts').PropsWithClientDirectives<${innerType}>): any {}`,
);
} else {
// Old svelte2tsx output
Expand All @@ -29,3 +36,56 @@ export function toTSX(code: string, className: string): string {

return result;
}

// Extracts generic type parameters from svelte2tsx output for generic Svelte components
//
// Given: `class __sveltets_Render<T extends Record<string, unknown>, U extends keyof T>`,
// Returns: `{ params: "T extends Record<string, unknown>, U extends keyof T", names: "T, U" }`
//
// `params` is the full declaration (used to define the generic function),
// `names` is just the identifiers (used to pass type args to __sveltets_Render).
//
// Returns null when the input has no __sveltets_Render class (non-generic component).
export function extractGenerics(tsx: string): { params: string; names: string } | null {
const marker = 'class __sveltets_Render<';
const startIdx = tsx.indexOf(marker);
if (startIdx === -1) return null;

// Find the matching `>` that closes the generic parameter list,
// tracking `<>` depth and skipping `=>` (arrow return types)
const genericStart = startIdx + marker.length;
let depth = 1;
let i = genericStart;
while (i < tsx.length && depth > 0) {
if (tsx[i] === '<') depth++;
if (tsx[i] === '>' && tsx[i - 1] !== '=') depth--;
i++;
}
const params = tsx.substring(genericStart, i - 1);

// Split params by top-level commas to extract individual type parameter names.
// Tracks bracket depth for `<>`, `()`, `{}`, `[]` to skip commas inside
// nested types like `Record<string, unknown>`, `[string, number]`,
// `{ a: string, b: number }`, or `(a: number, b: string) => void`
const names: string[] = [];
let current = '';
let depth2 = 0;
let prev = '';
for (const ch of params) {
if (ch === '<' || ch === '(' || ch === '{' || ch === '[') depth2++;
if ((ch === '>' && prev !== '=') || ch === ')' || ch === '}' || ch === ']') depth2--;
if (ch === ',' && depth2 === 0) {
// Skip variance/const modifiers to get the actual type parameter name
const name = genericNameRE.exec(current.trim())?.[1];
if (name) names.push(name);
current = '';
} else {
current += ch;
}
prev = ch;
}
const lastName = genericNameRE.exec(current.trim())?.[1];
if (lastName) names.push(lastName);

return { params, names: names.join(', ') };
}
78 changes: 78 additions & 0 deletions packages/integrations/svelte/test/check.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,84 @@ describe('Svelte Check', () => {
);
});

it('should pass check for generic component with correct props', async () => {
const root = fileURLToPath(new URL('./fixtures/prop-types/types/generics', import.meta.url));
const tsConfigPath = fileURLToPath(
new URL('./fixtures/prop-types/tsconfig.generics-pass.json', import.meta.url),
);
const { getResult } = cli('check', '--tsconfig', tsConfigPath, '--root', root);
const { exitCode, stdout, stderr } = await getResult();

if (exitCode !== 0) {
console.error(stdout);
console.error(stderr);
}
assert.equal(exitCode, 0, 'Expected check to pass (exit code 0)');
});

it('should fail check for generic component with incorrect props', async () => {
const root = fileURLToPath(new URL('./fixtures/prop-types/types/generics', import.meta.url));
const tsConfigPath = fileURLToPath(
new URL('./fixtures/prop-types/tsconfig.generics-fail.json', import.meta.url),
);
const { getResult } = cli('check', '--tsconfig', tsConfigPath, '--root', root);
const { exitCode } = await getResult();

assert.equal(exitCode, 1, 'Expected check to fail (exit code 1)');
});

it('should pass check for multi-param generic with nested angle brackets', async () => {
const root = fileURLToPath(new URL('./fixtures/prop-types/types/generics', import.meta.url));
const tsConfigPath = fileURLToPath(
new URL('./fixtures/prop-types/tsconfig.generics-multi-pass.json', import.meta.url),
);
const { getResult } = cli('check', '--tsconfig', tsConfigPath, '--root', root);
const { exitCode, stdout, stderr } = await getResult();

if (exitCode !== 0) {
console.error(stdout);
console.error(stderr);
}
assert.equal(exitCode, 0, 'Expected check to pass (exit code 0)');
});

it('should fail check for multi-param generic with invalid key', async () => {
const root = fileURLToPath(new URL('./fixtures/prop-types/types/generics', import.meta.url));
const tsConfigPath = fileURLToPath(
new URL('./fixtures/prop-types/tsconfig.generics-multi-fail.json', import.meta.url),
);
const { getResult } = cli('check', '--tsconfig', tsConfigPath, '--root', root);
const { exitCode } = await getResult();

assert.equal(exitCode, 1, 'Expected check to fail (exit code 1)');
});

it('should pass check for generic with arrow function constraint', async () => {
const root = fileURLToPath(new URL('./fixtures/prop-types/types/generics', import.meta.url));
const tsConfigPath = fileURLToPath(
new URL('./fixtures/prop-types/tsconfig.generics-arrow-pass.json', import.meta.url),
);
const { getResult } = cli('check', '--tsconfig', tsConfigPath, '--root', root);
const { exitCode, stdout, stderr } = await getResult();

if (exitCode !== 0) {
console.error(stdout);
console.error(stderr);
}
assert.equal(exitCode, 0, 'Expected check to pass (exit code 0)');
});

it('should fail check for generic with arrow function returning wrong type', async () => {
const root = fileURLToPath(new URL('./fixtures/prop-types/types/generics', import.meta.url));
const tsConfigPath = fileURLToPath(
new URL('./fixtures/prop-types/tsconfig.generics-arrow-fail.json', import.meta.url),
);
const { getResult } = cli('check', '--tsconfig', tsConfigPath, '--root', root);
const { exitCode } = await getResult();

assert.equal(exitCode, 1, 'Expected check to fail (exit code 1)');
});

it('should fail check on invalid element children', { skip: true }, async () => {
const root = fileURLToPath(new URL('./fixtures/prop-types/types/children', import.meta.url));
const tsConfigPath = fileURLToPath(
Expand Down
146 changes: 146 additions & 0 deletions packages/integrations/svelte/test/extract-generics.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { extractGenerics } from '../dist/editor.cjs';

describe('extractGenerics', () => {
it('should return null when no __sveltets_Render class is present', () => {
const tsx = 'const $$Component = __sveltets_2_isomorphic_component($$render());';
assert.equal(extractGenerics(tsx), null);
});

it('should extract a single type parameter', () => {
const tsx = 'class __sveltets_Render<T> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, { params: 'T', names: 'T' });
});

it('should extract a single type parameter with constraint', () => {
const tsx = 'class __sveltets_Render<T extends boolean = false> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, { params: 'T extends boolean = false', names: 'T' });
});

it('should extract multiple type parameters', () => {
const tsx = 'class __sveltets_Render<T, U> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, { params: 'T, U', names: 'T, U' });
});

it('should extract multiple type parameters with constraints', () => {
const tsx = 'class __sveltets_Render<T extends string, U extends number = 0> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, {
params: 'T extends string, U extends number = 0',
names: 'T, U',
});
});

it('should handle nested angle brackets in constraints', () => {
const tsx = 'class __sveltets_Render<T extends Record<string, unknown>, U extends keyof T> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, {
params: 'T extends Record<string, unknown>, U extends keyof T',
names: 'T, U',
});
});

it('should handle deeply nested angle brackets', () => {
const tsx = 'class __sveltets_Render<T extends Map<string, Set<number>>> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, {
params: 'T extends Map<string, Set<number>>',
names: 'T',
});
});

it('should handle arrow function types in constraints', () => {
const tsx = 'class __sveltets_Render<T extends (val: string) => boolean> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, {
params: 'T extends (val: string) => boolean',
names: 'T',
});
});

it('should handle arrow function returning generic type', () => {
const tsx = 'class __sveltets_Render<T extends (x: number) => Promise<string>> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, {
params: 'T extends (x: number) => Promise<string>',
names: 'T',
});
});

it('should handle arrow function with multiple params after comma', () => {
const tsx = 'class __sveltets_Render<T extends (a: string) => boolean, U extends T> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, {
params: 'T extends (a: string) => boolean, U extends T',
names: 'T, U',
});
});

it('should handle const modifier on type parameter', () => {
const tsx = 'class __sveltets_Render<const T extends readonly string[]> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, {
params: 'const T extends readonly string[]',
names: 'T',
});
});

it('should handle object literal with commas in constraint', () => {
const tsx = 'class __sveltets_Render<T extends { a: string, b: number }> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, {
params: 'T extends { a: string, b: number }',
names: 'T',
});
});

it('should handle object literal with commas and a second param', () => {
const tsx =
'class __sveltets_Render<T extends { a: string, b: number }, U extends keyof T> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, {
params: 'T extends { a: string, b: number }, U extends keyof T',
names: 'T, U',
});
});

it('should handle tuple type with commas in constraint', () => {
const tsx = 'class __sveltets_Render<T extends [string, number]> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, {
params: 'T extends [string, number]',
names: 'T',
});
});

it('should handle parenthesized function params with commas', () => {
const tsx = 'class __sveltets_Render<T extends (a: number, b: string) => void> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, {
params: 'T extends (a: number, b: string) => void',
names: 'T',
});
});

it('should handle nested object literals with commas', () => {
const tsx = 'class __sveltets_Render<T extends { a: { x: string }, b: number }> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, {
params: 'T extends { a: { x: string }, b: number }',
names: 'T',
});
});

it('should handle nested arrow function in constraint', () => {
const tsx = 'class __sveltets_Render<T extends (f: (x: string) => number) => boolean> { }';
const result = extractGenerics(tsx);
assert.deepEqual(result, {
params: 'T extends (f: (x: string) => number) => boolean',
names: 'T',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["types/generics/arrow-fail.astro", "types/generics/ArrowGeneric.svelte"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["types/generics/arrow-pass.astro", "types/generics/ArrowGeneric.svelte"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["types/generics/fail.astro", "types/generics/GenericList.svelte"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["types/generics/multi-fail.astro", "types/generics/MultiGeneric.svelte"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["types/generics/multi-pass.astro", "types/generics/MultiGeneric.svelte"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["types/generics/pass.astro", "types/generics/GenericList.svelte"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<script lang="ts" generics="T extends (val: string) => boolean">
interface Props {
predicate: T;
value: string;
}

let { predicate, value }: Props = $props();
</script>

<p>{predicate(value)}</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script lang="ts" generics="T extends 'ordered' | 'unordered' = 'unordered'">
interface Props {
variant?: T;
items: T extends 'ordered' ? number[] : string[];
}

let { variant, items }: Props = $props();
</script>

<ul>
{#each items as item}
<li>{item} ({variant})</li>
{/each}
</ul>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends keyof T">
interface Props {
data: T;
key: U;
}

let { data, key }: Props = $props();
</script>

<p>{String(data[key])}</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
import ArrowGeneric from './ArrowGeneric.svelte';
---

<ArrowGeneric predicate={(val: string) => 123} value="test" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
import ArrowGeneric from './ArrowGeneric.svelte';
---

<ArrowGeneric predicate={(val: string) => val.length > 0} value="test" />
Loading
Loading