Skip to content

Commit 67e55e5

Browse files
authored
Fix wrapper function prop handling and interface extension (#12)
* Add new test-cases * Fixed missing destructured
1 parent 28ea036 commit 67e55e5

12 files changed

Lines changed: 427 additions & 35 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,6 @@ playwright-report
147147

148148
# Screenshots
149149
*.png
150+
151+
# Test case actual outputs (generated)
152+
test-cases/*.actual.tsx

src/internal/collect-styled-decls.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,7 @@ export function collectStyledDecls(args: {
771771
rules,
772772
templateExpressions: parsed.slots.map((s) => s.expression),
773773
rawCss: parsed.rawCss,
774+
...(propsType ? { propsType } : {}),
774775
...(leadingComments ? { leadingComments } : {}),
775776
});
776777
}

src/internal/emit-wrappers.ts

Lines changed: 213 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,43 @@ export function emitWrappers(args: {
466466
return interfaces.size() > 0;
467467
};
468468

469+
/**
470+
* Extends an existing interface with a base type.
471+
* Returns true if the interface was found and extended, false otherwise.
472+
*/
473+
const extendExistingInterface = (typeName: string, baseTypeText: string): boolean => {
474+
if (!emitTypes) {
475+
return false;
476+
}
477+
const interfaces = root.find(j.TSInterfaceDeclaration, {
478+
id: { type: "Identifier", name: typeName },
479+
} as any);
480+
if (interfaces.size() === 0) {
481+
return false;
482+
}
483+
// Parse the base type into a TSExpressionWithTypeArguments node
484+
const parsed = j(`interface X extends ${baseTypeText} {}`).get().node.program.body[0] as any;
485+
const extendsClause = parsed?.extends?.[0];
486+
if (!extendsClause) {
487+
return false;
488+
}
489+
interfaces.forEach((path: any) => {
490+
const iface = path.node;
491+
// Don't add if already extends this type
492+
const existingExtends = iface.extends ?? [];
493+
const alreadyExtends = existingExtends.some((ext: any) => {
494+
const extStr = j(ext).toSource();
495+
return extStr === baseTypeText;
496+
});
497+
if (alreadyExtends) {
498+
return;
499+
}
500+
// Add the extends clause
501+
iface.extends = [...existingExtends, extendsClause];
502+
});
503+
return true;
504+
};
505+
469506
/**
470507
* Emits a named props type alias and returns whether it was emitted.
471508
* Returns false if the type would shadow an existing type with the same name.
@@ -1656,11 +1693,15 @@ export function emitWrappers(args: {
16561693
// If there's an explicit type, extend it with base component props
16571694
const typeText = explicit ? `${baseTypeText} & ${explicit}` : baseTypeText;
16581695
const typeAliasEmitted = emitNamedPropsType(d.localName, typeText);
1659-
// If the type alias was not emitted (e.g., due to shadowing), store the type
1660-
// for inline annotation in the function parameter
1696+
// If the type alias was not emitted (e.g., due to shadowing), try to extend
1697+
// the existing interface with the base component props
16611698
if (!typeAliasEmitted && explicit) {
1662-
// Use PropsWithChildren wrapper with style for inline annotation
1663-
inlineTypeText = `React.PropsWithChildren<${explicit} & { style?: React.CSSProperties }>`;
1699+
const propsTypeName = propsTypeNameFor(d.localName);
1700+
const extended = extendExistingInterface(propsTypeName, baseTypeText);
1701+
if (!extended) {
1702+
// Fallback: use inline type annotation
1703+
inlineTypeText = `React.PropsWithChildren<${explicit} & { style?: React.CSSProperties }>`;
1704+
}
16641705
}
16651706
needsReactTypeImport = true;
16661707
}
@@ -1720,6 +1761,34 @@ export function emitWrappers(args: {
17201761
}
17211762
}
17221763

1764+
// Add style function calls for dynamic prop-based styles (e.g., color: ${props => props.color})
1765+
const styleFnPairs = d.styleFnFromProps ?? [];
1766+
for (const p of styleFnPairs) {
1767+
const propExpr = j.identifier(p.jsxProp);
1768+
const call = j.callExpression(
1769+
j.memberExpression(j.identifier("styles"), j.identifier(p.fnKey)),
1770+
[propExpr as any],
1771+
);
1772+
// Add prop to destructure list
1773+
if (!destructureProps.includes(p.jsxProp)) {
1774+
destructureProps.push(p.jsxProp);
1775+
}
1776+
// Check if prop is required in the props type
1777+
const required = isPropRequiredInPropsTypeLiteral(d.propsType, p.jsxProp);
1778+
if (required) {
1779+
styleArgs.push(call);
1780+
} else {
1781+
// Use `!= null` so `0` / empty strings still count as "provided".
1782+
styleArgs.push(
1783+
j.logicalExpression(
1784+
"&&",
1785+
j.binaryExpression("!=", propExpr as any, j.nullLiteral()),
1786+
call,
1787+
),
1788+
);
1789+
}
1790+
}
1791+
17231792
// When supportsExternalStyles is true, generate wrapper with className/style merging
17241793
if (supportsExternalStyles) {
17251794
const isVoidTag = VOID_TAGS.has(tagName);
@@ -1735,6 +1804,8 @@ export function emitWrappers(args: {
17351804
patternProp("className", classNameId),
17361805
...(isVoidTag ? [] : [patternProp("children", childrenId)]),
17371806
patternProp("style", styleId),
1807+
// Include variant props and style function props in destructuring
1808+
...destructureProps.map((name) => patternProp(name)),
17381809
j.restElement(restId),
17391810
];
17401811
const declStmt = j.variableDeclaration("const", [
@@ -1839,10 +1910,14 @@ export function emitWrappers(args: {
18391910
const wrappedComponent = d.base.ident;
18401911
{
18411912
const explicit = stringifyTsType(d.propsType);
1842-
emitNamedPropsType(
1843-
d.localName,
1844-
explicit ?? withChildren(`React.ComponentProps<typeof ${wrappedComponent}>`),
1845-
);
1913+
const baseTypeText = `React.ComponentProps<typeof ${wrappedComponent}>`;
1914+
const typeText = explicit ? `${baseTypeText} & ${explicit}` : withChildren(baseTypeText);
1915+
const typeAliasEmitted = emitNamedPropsType(d.localName, typeText);
1916+
// If the type alias was not emitted (e.g., due to shadowing), try to extend
1917+
// the existing interface with the base component props
1918+
if (!typeAliasEmitted && explicit) {
1919+
extendExistingInterface(propsTypeNameFor(d.localName), baseTypeText);
1920+
}
18461921
needsReactTypeImport = true;
18471922
}
18481923
const styleArgs: any[] = [
@@ -1852,6 +1927,80 @@ export function emitWrappers(args: {
18521927
j.memberExpression(j.identifier("styles"), j.identifier(d.styleKey)),
18531928
];
18541929

1930+
// Track props that need to be destructured for conditional styles
1931+
const destructureProps: string[] = [];
1932+
1933+
// Add variant style arguments if this component has variants
1934+
if (d.variantStyleKeys) {
1935+
for (const [when, variantKey] of Object.entries(d.variantStyleKeys)) {
1936+
// Parse the supported expression subset into AST
1937+
let cond: any = null;
1938+
const trimmed = when.trim();
1939+
let propName = "";
1940+
1941+
if (trimmed.startsWith("!(") && trimmed.endsWith(")")) {
1942+
const inner = trimmed.slice(2, -1).trim();
1943+
cond = j.unaryExpression("!", j.identifier(inner));
1944+
propName = inner;
1945+
} else if (trimmed.startsWith("!")) {
1946+
propName = trimmed.slice(1);
1947+
cond = j.unaryExpression("!", j.identifier(propName));
1948+
} else if (trimmed.includes("===") || trimmed.includes("!==")) {
1949+
const op = trimmed.includes("!==") ? "!==" : "===";
1950+
const [lhs, rhsRaw0] = trimmed.split(op).map((s) => s.trim());
1951+
const rhsRaw = rhsRaw0 ?? "";
1952+
const rhs =
1953+
rhsRaw?.startsWith('"') || rhsRaw?.startsWith("'")
1954+
? j.literal(JSON.parse(rhsRaw.replace(/^'/, '"').replace(/'$/, '"')))
1955+
: /^-?\d+(\.\d+)?$/.test(rhsRaw)
1956+
? j.literal(Number(rhsRaw))
1957+
: j.identifier(rhsRaw);
1958+
propName = lhs ?? "";
1959+
cond = j.binaryExpression(op, j.identifier(propName), rhs);
1960+
} else {
1961+
propName = trimmed;
1962+
cond = j.identifier(trimmed);
1963+
}
1964+
1965+
if (propName && !destructureProps.includes(propName)) {
1966+
destructureProps.push(propName);
1967+
}
1968+
1969+
styleArgs.push(
1970+
j.logicalExpression(
1971+
"&&",
1972+
cond,
1973+
j.memberExpression(j.identifier("styles"), j.identifier(variantKey)),
1974+
),
1975+
);
1976+
}
1977+
}
1978+
1979+
// Add style function calls for dynamic prop-based styles
1980+
const styleFnPairs = d.styleFnFromProps ?? [];
1981+
for (const p of styleFnPairs) {
1982+
const propExpr = j.identifier(p.jsxProp);
1983+
const call = j.callExpression(
1984+
j.memberExpression(j.identifier("styles"), j.identifier(p.fnKey)),
1985+
[propExpr as any],
1986+
);
1987+
if (!destructureProps.includes(p.jsxProp)) {
1988+
destructureProps.push(p.jsxProp);
1989+
}
1990+
const required = isPropRequiredInPropsTypeLiteral(d.propsType, p.jsxProp);
1991+
if (required) {
1992+
styleArgs.push(call);
1993+
} else {
1994+
styleArgs.push(
1995+
j.logicalExpression(
1996+
"&&",
1997+
j.binaryExpression("!=", propExpr as any, j.nullLiteral()),
1998+
call,
1999+
),
2000+
);
2001+
}
2002+
}
2003+
18552004
const propsParamId = j.identifier("props");
18562005
annotatePropsParam(propsParamId, d.localName);
18572006
const propsId = j.identifier("props");
@@ -1861,6 +2010,9 @@ export function emitWrappers(args: {
18612010
);
18622011

18632012
// Create: <WrappedComponent {...props} {...stylex.props(styles.key)} />
2013+
// or with destructuring if we have conditional/dynamic props:
2014+
// const { prop, style, ...rest } = props;
2015+
// return <WrappedComponent {...rest} {...stylex.props(styles.key, prop && styles.keyProp)} style={style} />
18642016
// Handle both simple identifiers (Button) and member expressions (animated.div)
18652017
let jsxTagName: any;
18662018
if (wrappedComponent.includes(".")) {
@@ -1872,23 +2024,60 @@ export function emitWrappers(args: {
18722024
} else {
18732025
jsxTagName = j.jsxIdentifier(wrappedComponent);
18742026
}
1875-
const jsx = j.jsxElement(
1876-
j.jsxOpeningElement(
1877-
jsxTagName,
1878-
[j.jsxSpreadAttribute(propsId), j.jsxSpreadAttribute(stylexPropsCall)],
1879-
true,
1880-
),
1881-
null,
1882-
[],
1883-
);
18842027

1885-
emitted.push(
1886-
j.functionDeclaration(
1887-
j.identifier(d.localName),
1888-
[propsParamId],
1889-
j.blockStatement([j.returnStatement(jsx as any)]),
1890-
),
1891-
);
2028+
// If we have props to destructure, create a proper destructuring pattern
2029+
if (destructureProps.length > 0) {
2030+
const styleId = j.identifier("style");
2031+
const restId = j.identifier("rest");
2032+
const patternProps: any[] = destructureProps.map((name) => patternProp(name));
2033+
patternProps.push(patternProp("style", styleId));
2034+
patternProps.push(j.restElement(restId));
2035+
2036+
const declStmt = j.variableDeclaration("const", [
2037+
j.variableDeclarator(j.objectPattern(patternProps as any), propsId),
2038+
]);
2039+
2040+
const jsx = j.jsxElement(
2041+
j.jsxOpeningElement(
2042+
jsxTagName,
2043+
[
2044+
j.jsxSpreadAttribute(restId),
2045+
j.jsxSpreadAttribute(stylexPropsCall),
2046+
j.jsxAttribute(j.jsxIdentifier("style"), j.jsxExpressionContainer(styleId)),
2047+
],
2048+
true,
2049+
),
2050+
null,
2051+
[],
2052+
);
2053+
2054+
emitted.push(
2055+
j.functionDeclaration(
2056+
j.identifier(d.localName),
2057+
[propsParamId],
2058+
j.blockStatement([declStmt, j.returnStatement(jsx as any)]),
2059+
),
2060+
);
2061+
} else {
2062+
// Simple case: just spread props and stylex.props
2063+
const jsx = j.jsxElement(
2064+
j.jsxOpeningElement(
2065+
jsxTagName,
2066+
[j.jsxSpreadAttribute(propsId), j.jsxSpreadAttribute(stylexPropsCall)],
2067+
true,
2068+
),
2069+
null,
2070+
[],
2071+
);
2072+
2073+
emitted.push(
2074+
j.functionDeclaration(
2075+
j.identifier(d.localName),
2076+
[propsParamId],
2077+
j.blockStatement([j.returnStatement(jsx as any)]),
2078+
),
2079+
);
2080+
}
18922081
}
18932082

18942083
if (emitted.length > 0) {

src/transform.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,17 @@ export function transformWithWarnings(
145145
try {
146146
const program = j(`(${exprSource});`);
147147
const stmt = program.find(j.ExpressionStatement).nodes()[0];
148-
return (stmt as any)?.expression ?? null;
148+
let expr = (stmt as any)?.expression ?? null;
149+
// Unwrap ParenthesizedExpression to avoid extra parentheses in output
150+
while (expr?.type === "ParenthesizedExpression") {
151+
expr = expr.expression;
152+
}
153+
// Remove extra.parenthesized flag that causes recast to add parentheses
154+
if (expr?.extra?.parenthesized) {
155+
delete expr.extra.parenthesized;
156+
delete expr.extra.parenStart;
157+
}
158+
return expr;
149159
} catch {
150160
return null;
151161
}

test-cases/attrs.output.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,14 @@ const styles = stylex.create({
4444
});
4545

4646
export function Background(props: BackgroundProps) {
47-
return <Flex {...props} {...stylex.props(styles.background)} />;
47+
const { loaded, style, ...rest } = props;
48+
return (
49+
<Flex
50+
{...rest}
51+
{...stylex.props(styles.background, loaded && styles.backgroundLoaded)}
52+
style={style}
53+
/>
54+
);
4855
}
4956

5057
export function Scrollable(props: ScrollableProps) {
@@ -58,13 +65,13 @@ export interface TextInputProps {
5865

5966
// Pattern 3: styled(Component).attrs with object
6067
// This pattern passes static attrs as an object
61-
interface BackgroundProps {
68+
interface BackgroundProps extends React.ComponentProps<typeof Flex> {
6269
loaded: boolean;
6370
}
6471

6572
// Pattern 4: styled(Component).attrs with function (from Scrollable.tsx)
6673
// This pattern computes attrs from props
67-
interface ScrollableProps {
74+
interface ScrollableProps extends React.ComponentProps<typeof Flex> {
6875
gutter?: string;
6976
}
7077

test-cases/duplicate-type-identifier.output.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const styles = stylex.create({
2323
});
2424

2525
// The styled component uses the existing props interface
26-
export function Card(props: React.PropsWithChildren<CardProps & { style?: React.CSSProperties }>) {
26+
export function Card(props: CardProps) {
2727
const { children, style, highlighted, ...rest } = props;
2828
return (
2929
<div
@@ -51,7 +51,7 @@ export function IconButton(props: IconButtonProps) {
5151
/**
5252
* Card props
5353
*/
54-
export interface CardProps {
54+
export interface CardProps extends React.ComponentProps<"div"> {
5555
/** Title of the card */
5656
title: string;
5757
/** Whether the card is highlighted */

0 commit comments

Comments
 (0)