diff --git a/.changeset/seven-toes-tell.md b/.changeset/seven-toes-tell.md new file mode 100644 index 00000000..435f8ea9 --- /dev/null +++ b/.changeset/seven-toes-tell.md @@ -0,0 +1,8 @@ +--- +"@astrojs/compiler": minor +--- + +fixed a bug where the Astro compiler incorrectly handled the 'as' property name in Props interfaces. + +This allows Astro components to use 'as' as a prop name (common pattern for polymorphic components) without breaking TypeScript type inference. The Props type is now correctly preserved when destructuring objects with an 'as' +property. \ No newline at end of file diff --git a/internal/js_scanner/js_scanner.go b/internal/js_scanner/js_scanner.go index 731413d9..f2df00e1 100644 --- a/internal/js_scanner/js_scanner.go +++ b/internal/js_scanner/js_scanner.go @@ -229,6 +229,12 @@ func isKeyword(value []byte) bool { return js.Keywords[string(value)] != 0 } +// isPropsAliasing checks if we're in a Props aliasing context (import { Props as X }) +// rather than destructuring with 'as' property ({ as: Component }) +func isPropsAliasing(idents []string) bool { + return len(idents) > 0 && idents[len(idents)-1] == "Props" +} + func HoistImports(source []byte) HoistedScripts { imports := make([][]byte, 0) importLocs := make([]loc.Loc, 0) @@ -340,7 +346,8 @@ outer: if js.IsIdentifier(token) { if isKeyword(value) { // fix(#814): fix Props detection when using `{ Props as SomethingElse }` - if ident == "Props" && string(value) == "as" { + // fix(#927): only reset Props when 'as' follows 'Props' in the same context + if ident == "Props" && string(value) == "as" && isPropsAliasing(idents) { start = 0 ident = defaultPropType idents = make([]string, 0) diff --git a/internal/js_scanner/js_scanner_test.go b/internal/js_scanner/js_scanner_test.go index cfdfc59f..8d16b8f6 100644 --- a/internal/js_scanner/js_scanner_test.go +++ b/internal/js_scanner/js_scanner_test.go @@ -775,3 +775,103 @@ func TestGetObjectKeys(t *testing.T) { }) } } + +// propsTestCase represents a test case for GetPropsType +type propsTestCase struct { + name string + source string + want Props +} + +// makeProps is a helper to create Props structs concisely +func makeProps(ident string, statement string, generics string) Props { + return Props{ + Ident: ident, + Statement: statement, + Generics: generics, + } +} + +// getPropsTypeTestCases returns all test cases for GetPropsType +func getPropsTypeTestCases() []propsTestCase { + const defaultType = "Record" + + return []propsTestCase{ + // Basic cases + { + name: "no props", + source: `const foo = "bar"`, + want: makeProps(defaultType, "", ""), + }, + { + name: "interface Props", + source: `interface Props { + foo: string; + }`, + want: makeProps("Props", "", ""), + }, + { + name: "type Props", + source: `type Props = { + foo: string; + }`, + want: makeProps("Props", "", ""), + }, + + // Generics + { + name: "Props with generics", + source: `interface Props { + foo: T; + }`, + want: makeProps("Props", "", ""), + }, + + // Issue #927: 'as' prop name handling + { + name: "destructuring with 'as' prop name without type assertion - issue #927", + source: `interface Props { + as?: string; + href?: string; + } + const { as: Component, href } = Astro.props;`, + want: makeProps("Props", "", ""), + }, + { + name: "destructuring with 'as' prop name with type assertion", + source: `interface Props { + as?: string; + href?: string; + } + const { as: Component, href } = Astro.props as Props;`, + want: makeProps("Props", "", ""), + }, + } +} + +// checks if two Props are equal and reports errors +func assertPropsEqual(t *testing.T, got, want Props, source string) { + t.Helper() + + if got.Ident != want.Ident { + t.Errorf("Ident mismatch:\n got: %q\n want: %q", got.Ident, want.Ident) + t.Logf("Source:\n%s", source) + } + if got.Statement != want.Statement { + t.Errorf("Statement mismatch:\n got: %q\n want: %q", got.Statement, want.Statement) + } + if got.Generics != want.Generics { + t.Errorf("Generics mismatch:\n got: %q\n want: %q", got.Generics, want.Generics) + } +} + +func TestGetPropsType(t *testing.T) { + tests := getPropsTypeTestCases() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetPropsType([]byte(tt.source)) + assertPropsEqual(t, got, tt.want, tt.source) + }) + } +} diff --git a/packages/compiler/test/tsx/props.ts b/packages/compiler/test/tsx/props.ts index 3fc8b104..5034cd06 100644 --- a/packages/compiler/test/tsx/props.ts +++ b/packages/compiler/test/tsx/props.ts @@ -289,4 +289,150 @@ export default function __AstroComponent_(_props: Record): any {}\n assert.snapshot(code, output, 'expected code to match snapshot'); }); +test('props interface with as property', async () => { + const input = `--- +interface Props { + as?: string; + href?: string; +} +--- + +
+`; + const output = `${TSXPrefix} +interface Props { + as?: string; + href?: string; +} + +{}; +
+ +
+export default function __AstroComponent_(_props: Props): any {} +${PREFIX()}`; + const { code } = await convertToTSX(input, { sourcemap: 'external' }); + assert.snapshot(code, output, 'expected code to match snapshot'); +}); + +test('props with destructured as property', async () => { + const input = `--- +interface Props { + as?: string; + className?: string; +} + +const { as, className } = Astro.props; +--- + +
{as}
+`; + const output = `${TSXPrefix} +interface Props { + as?: string; + className?: string; +} + +const { as, className } = Astro.props; + + +
{as}
+ +
+export default function __AstroComponent_(_props: Props): any {} +${PREFIX()}`; + const { code } = await convertToTSX(input, { sourcemap: 'external' }); + assert.snapshot(code, output, 'expected code to match snapshot'); +}); + +test('props with renamed as property in destructuring', async () => { + const input = `--- +interface Props { + as?: string; +} + +const { as: element } = Astro.props; +--- + +
{element}
+`; + const output = `${TSXPrefix} +interface Props { + as?: string; +} + +const { as: element } = Astro.props; + + +
{element}
+ +
+export default function __AstroComponent_(_props: Props): any {} +${PREFIX()}`; + const { code } = await convertToTSX(input, { sourcemap: 'external' }); + assert.snapshot(code, output, 'expected code to match snapshot'); +}); + +test('props interface with as and other properties', async () => { + const input = `--- +interface Props extends HTMLAttributes<'div'> { + as?: keyof HTMLElementTagNameMap; + variant?: 'primary' | 'secondary'; + size?: 'sm' | 'md' | 'lg'; +} + +const { as = 'div', variant = 'primary', size = 'md', ...rest } = Astro.props; +--- + +
+`; + const output = `${TSXPrefix} +interface Props extends HTMLAttributes<'div'> { + as?: keyof HTMLElementTagNameMap; + variant?: 'primary' | 'secondary'; + size?: 'sm' | 'md' | 'lg'; +} + +const { as = 'div', variant = 'primary', size = 'md', ...rest } = Astro.props; + + +
+ +
+export default function __AstroComponent_(_props: Props): any {} +${PREFIX()}`; + const { code } = await convertToTSX(input, { sourcemap: 'external' }); + assert.snapshot(code, output, 'expected code to match snapshot'); +}); + +test('props type alias with as property', async () => { + const input = `--- +type Props = { + as?: string; + children?: any; +} + +const props = Astro.props as Props; +--- + +
{props.children}
+`; + const output = `${TSXPrefix} +type Props = { + as?: string; + children?: any; +} + +const props = Astro.props as Props; + + +
{props.children}
+ +
+export default function __AstroComponent_(_props: Props): any {} +${PREFIX()}`; + const { code } = await convertToTSX(input, { sourcemap: 'external' }); + assert.snapshot(code, output, 'expected code to match snapshot'); +}); + test.run();