Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
53 changes: 49 additions & 4 deletions packages/integrations/svelte/src/editor.cts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,19 @@ export function toTSX(code: string, className: string): string {

// New svelte2tsx output (Svelte 5)
if (tsx.includes('export default $$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 {}`,
);
const generics = extractGenerics(tsx);
if (generics) {
// Generic component: preserve type params using __sveltets_Render
result = tsx.replace(
'export default $$Component;',
`export default function ${className}__AstroComponent_<${generics.params}>(_props: import('@astrojs/svelte/svelte-shims.d.ts').PropsWithClientDirectives<ReturnType<__sveltets_Render<${generics.names}>['props']>>): any {}`,
);
} else {
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 {}`,
);
}
} else {
// Old svelte2tsx output
result = tsx.replace(
Expand All @@ -29,3 +38,39 @@ export function toTSX(code: string, className: string): string {

return result;
}

function extractGenerics(tsx: string): { params: string; names: string } | null {
Comment thread
seroperson marked this conversation as resolved.
Outdated
const marker = 'class __sveltets_Render<';
const startIdx = tsx.indexOf(marker);
if (startIdx === -1) return null;

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);

// Extract just the type parameter names by splitting at top-level commas
const names: string[] = [];
let current = '';
let d = 0;
for (const ch of params) {
if (ch === '<') d++;
if (ch === '>') d--;
if (ch === ',' && d === 0) {
const name = /^(\w+)/.exec(current.trim())?.[1];
if (name) names.push(name);
current = '';
} else {
current += ch;
}
}
const lastName = /^(\w+)/.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
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" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
import GenericList from './GenericList.svelte';
---

<GenericList variant="ordered" items={["wrong", "type"]} />
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
import MultiGeneric from './MultiGeneric.svelte';
---

<MultiGeneric data={{ name: "test" }} key="missing" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
import MultiGeneric from './MultiGeneric.svelte';
---

<MultiGeneric data={{ name: "test", age: 42 }} key="name" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
import GenericList from './GenericList.svelte';
---

<GenericList items={["a", "b", "c"]} />
<GenericList variant="ordered" items={[1, 2, 3]} />
Loading