Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/seven-toes-tell.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion internal/js_scanner/js_scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
100 changes: 100 additions & 0 deletions internal/js_scanner/js_scanner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>"

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<T> {
foo: T;
}`,
want: makeProps("Props", "<T>", "<T>"),
},

// 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)
})
}
}
146 changes: 146 additions & 0 deletions packages/compiler/test/tsx/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,4 +289,150 @@ export default function __AstroComponent_(_props: Record<string, any>): 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;
}
---

<div></div>
`;
const output = `${TSXPrefix}
interface Props {
as?: string;
href?: string;
}

{};<Fragment>
<div></div>

</Fragment>
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;
---

<div class={className}>{as}</div>
`;
const output = `${TSXPrefix}
interface Props {
as?: string;
className?: string;
}

const { as, className } = Astro.props;

<Fragment>
<div class={className}>{as}</div>

</Fragment>
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;
---

<div>{element}</div>
`;
const output = `${TSXPrefix}
interface Props {
as?: string;
}

const { as: element } = Astro.props;

<Fragment>
<div>{element}</div>

</Fragment>
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;
---

<div data-variant={variant} data-size={size}></div>
`;
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;

<Fragment>
<div data-variant={variant} data-size={size}></div>

</Fragment>
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;
---

<div>{props.children}</div>
`;
const output = `${TSXPrefix}
type Props = {
as?: string;
children?: any;
}

const props = Astro.props as Props;

<Fragment>
<div>{props.children}</div>

</Fragment>
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();
Loading