Skip to content

Commit fa9c464

Browse files
authored
fix(svelte): improve Svelte children prop type checking (#15070)
1 parent 4dacd36 commit fa9c464

File tree

16 files changed

+186
-6
lines changed

16 files changed

+186
-6
lines changed

.changeset/every-carpets-grin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/svelte': patch
3+
---
4+
5+
Improve Svelte `children` prop type checking

packages/integrations/svelte/svelte-shims.d.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,62 @@ import type { JSX } from 'astro/jsx-runtime';
44

55
export type AstroClientDirectives = JSX.AstroComponentDirectives;
66

7+
type Snippet = import('svelte').Snippet;
8+
79
/**
8-
* Helper to detect index-signature-like keys (string or number).
10+
* Helper to detect index-signature-like keys.
911
*/
10-
type IsIndexSignatureKey<K> = string extends K ? true : number extends K ? true : false;
12+
type IsIndexSignatureKey<K> = string extends K
13+
? true
14+
: number extends K
15+
? true
16+
: symbol extends K
17+
? true
18+
: false;
1119

1220
/**
1321
* Removes index signatures whose value type is `never`.
1422
* (Keeps normal, explicitly-declared keys unchanged.)
1523
*/
1624
export type StripNeverIndexSignatures<T> = {
1725
[K in keyof T as IsIndexSignatureKey<K> extends true
18-
? T[K] extends never
26+
? [T[K]] extends [never]
1927
? never
2028
: K
2129
: K]: T[K];
2230
};
2331

2432
/**
25-
* Svelte component props plus Astro's client directives,
26-
* with `never` index signatures stripped out.
33+
* If `children` exists and is `never`, make it `{ children?: undefined }`
34+
* (works even when it was required).
35+
* If `children` doesn't exist, add `{ children?: undefined }`.
36+
*/
37+
type NormalizeNeverChildren<T> = 'children' extends keyof T
38+
? [T['children']] extends [never]
39+
? Omit<T, 'children'> & { children?: undefined }
40+
: T
41+
: T & { children?: undefined };
42+
43+
/**
44+
* If `children` includes `Snippet` (even as part of a union), widen to `any`
45+
* to allow arbitrary content.
46+
*/
47+
type WidenChildrenIfSnippet<T> = {
48+
[K in keyof T]: K extends 'children'
49+
? Extract<NonNullable<T[K]>, Snippet> extends never
50+
? T[K]
51+
: any
52+
: T[K];
53+
};
54+
55+
/**
56+
* `T` (Svelte props) made safe for Astro:
57+
* - Normalize `children` (avoid `never`/missing cases)
58+
* - Widen snippet-based `children` to `any` (Astro slot/content compatibility)
59+
* - Strip useless `never` index signatures (allow extra keys like `client:*`)
60+
* - Add Astro client directives
2761
*/
28-
export type PropsWithClientDirectives<T> = StripNeverIndexSignatures<T> & AstroClientDirectives;
62+
export type PropsWithClientDirectives<T> = StripNeverIndexSignatures<
63+
WidenChildrenIfSnippet<NormalizeNeverChildren<T>>
64+
> &
65+
AstroClientDirectives;

packages/integrations/svelte/test/check.test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,49 @@ describe('Svelte Check', () => {
3333
}
3434
assert.equal(exitCode, 0, 'Expected check to pass (exit code 0)');
3535
});
36+
37+
it('should pass check on valid children usage', async () => {
38+
const root = fileURLToPath(new URL('./fixtures/prop-types/types/children', import.meta.url));
39+
const tsConfigPath = fileURLToPath(
40+
new URL('./fixtures/prop-types/tsconfig.children-pass.json', import.meta.url),
41+
);
42+
const { getResult } = cli('check', '--tsconfig', tsConfigPath, '--root', root);
43+
const { exitCode, stdout, stderr } = await getResult();
44+
45+
if (exitCode !== 0) {
46+
console.error(stdout);
47+
console.error(stderr);
48+
}
49+
assert.equal(exitCode, 0, 'Expected check to pass (exit code 0)');
50+
});
51+
52+
it('should fail check on invalid text children', async () => {
53+
const root = fileURLToPath(new URL('./fixtures/prop-types/types/children', import.meta.url));
54+
const tsConfigPath = fileURLToPath(
55+
new URL('./fixtures/prop-types/tsconfig.children-fail.json', import.meta.url),
56+
);
57+
const { getResult } = cli('check', '--tsconfig', tsConfigPath, '--root', root);
58+
const { exitCode, stdout } = await getResult();
59+
60+
assert.equal(exitCode, 1, 'Expected check to fail (exit code 1)');
61+
assert.ok(
62+
stdout.includes(`'Empty' components don't accept text`),
63+
'Expected Empty component error',
64+
);
65+
assert.ok(
66+
stdout.includes(`'EmptyV5' components don't accept text`),
67+
'Expected EmptyV5 component error',
68+
);
69+
});
70+
71+
it('should fail check on invalid element children', { skip: true }, async () => {
72+
const root = fileURLToPath(new URL('./fixtures/prop-types/types/children', import.meta.url));
73+
const tsConfigPath = fileURLToPath(
74+
new URL('./fixtures/prop-types/tsconfig.children-fail-element.json', import.meta.url),
75+
);
76+
const { getResult } = cli('check', '--tsconfig', tsConfigPath, '--root', root);
77+
const { exitCode } = await getResult();
78+
79+
assert.equal(exitCode, 1, 'Expected check to fail (exit code 1)');
80+
});
3681
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"include": ["types/children/FailElement.astro"]
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"include": ["types/children/Fail.astro"]
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"include": ["types/children/Pass.astro"]
4+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<script lang="ts">
2+
3+
interface Props {
4+
children: number;
5+
}
6+
7+
const { children }: Props = $props();
8+
</script>
9+
10+
<p>{children}</p>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p>hello world</p>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script lang="ts">
2+
const value = $state();
3+
</script>
4+
5+
<p>hello world</p>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<script lang="ts">
2+
import type { Snippet } from "svelte";
3+
4+
interface Props {
5+
children: Snippet;
6+
}
7+
8+
const { children }: Props = $props();
9+
</script>
10+
11+
{@render children()}

0 commit comments

Comments
 (0)