Skip to content

Commit a848fdb

Browse files
authored
As and transient fixes (#14)
1 parent 592aa0e commit a848fdb

10 files changed

Lines changed: 500 additions & 84 deletions

src/internal/emit-wrappers.ts

Lines changed: 311 additions & 61 deletions
Large diffs are not rendered by default.

src/transform.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,10 @@ export function transformWithWarnings(
10431043
}
10441044

10451045
const wrapperNames = new Set<string>();
1046+
// Track wrappers that have expression `as` values (not just string literals)
1047+
// These need generic polymorphic types to accept component-specific props
1048+
const expressionAsWrapperNames = new Set<string>();
1049+
10461050
for (const [baseName, children] of extendedBy.entries()) {
10471051
const names = [baseName, ...children];
10481052
const hasPolymorphicUsage = names.some((nm) => {
@@ -1088,6 +1092,38 @@ export function transformWithWarnings(
10881092
}
10891093
}
10901094

1095+
// Also check for `as` usage on intrinsic styled components
1096+
// (e.g., styled.span with as={animated.span})
1097+
for (const decl of styledDecls) {
1098+
if (decl.base.kind === "intrinsic" && !wrapperNames.has(decl.localName)) {
1099+
const el = root.find(j.JSXElement, {
1100+
openingElement: { name: { type: "JSXIdentifier", name: decl.localName } },
1101+
});
1102+
const asAttrs = el.find(j.JSXAttribute, { name: { type: "JSXIdentifier", name: "as" } });
1103+
const hasAs = asAttrs.size() > 0;
1104+
const hasForwardedAs =
1105+
el
1106+
.find(j.JSXAttribute, {
1107+
name: { type: "JSXIdentifier", name: "forwardedAs" },
1108+
})
1109+
.size() > 0;
1110+
if (hasAs || hasForwardedAs) {
1111+
wrapperNames.add(decl.localName);
1112+
// Check if any `as` value is an expression (not a string literal)
1113+
// e.g., as={animated.span} vs as="a"
1114+
const hasExpressionAs = asAttrs.some((path) => {
1115+
const value = path.node.value;
1116+
// JSXExpressionContainer means it's an expression like {animated.span}
1117+
// StringLiteral/Literal means it's a string like "a"
1118+
return value?.type === "JSXExpressionContainer";
1119+
});
1120+
if (hasExpressionAs) {
1121+
expressionAsWrapperNames.add(decl.localName);
1122+
}
1123+
}
1124+
}
1125+
}
1126+
10911127
for (const decl of styledDecls) {
10921128
if (wrapperNames.has(decl.localName)) {
10931129
decl.needsWrapperComponent = true;
@@ -1863,6 +1899,7 @@ export function transformWithWarnings(
18631899
filePath: file.path,
18641900
styledDecls,
18651901
wrapperNames,
1902+
expressionAsWrapperNames,
18661903
patternProp,
18671904
exportedComponents,
18681905
stylesIdentifier,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import * as React from "react";
2+
import styled from "styled-components";
3+
4+
// SpringValue simulates react-spring's animated value type
5+
type SpringValue<T> = { get(): T };
6+
7+
// AnimatedSpanProps accepts SpringValue for style properties (like react-spring)
8+
type AnimatedSpanProps = Omit<React.ComponentProps<"span">, "style"> & {
9+
style?: React.CSSProperties & { width?: number | string | SpringValue<number> };
10+
};
11+
12+
// Simulates react-spring's animated component which accepts SpringValue in styles
13+
const animated = {
14+
span: React.forwardRef<HTMLSpanElement, AnimatedSpanProps>((props, ref) => (
15+
<span ref={ref} {...props} style={props.style as React.CSSProperties} />
16+
)),
17+
};
18+
19+
// When as={animated.span} is used, the component should render as animated.span
20+
// This pattern is common with animation libraries like react-spring
21+
const AnimatedText = styled.span`
22+
font-variant-numeric: tabular-nums;
23+
overflow: visible;
24+
display: inline-flex;
25+
`;
26+
27+
type Props = {
28+
width: number | SpringValue<number>;
29+
children: React.ReactNode;
30+
};
31+
32+
export function AnimatedNumber(props: Props) {
33+
const { width, children } = props;
34+
35+
// When width is a SpringValue, we need to use animated.span
36+
const isAnimated = typeof width !== "number";
37+
38+
if (isAnimated) {
39+
// This should work: animated.span accepts SpringValue<number> for width
40+
return (
41+
<AnimatedText as={animated.span} style={{ width }}>
42+
{children}
43+
</AnimatedText>
44+
);
45+
}
46+
47+
return <AnimatedText style={{ width }}>{children}</AnimatedText>;
48+
}
49+
50+
export const App = () => <AnimatedNumber width={100}>42</AnimatedNumber>;
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import * as stylex from "@stylexjs/stylex";
2+
import * as React from "react";
3+
4+
// SpringValue simulates react-spring's animated value type
5+
type SpringValue<T> = { get(): T };
6+
7+
// AnimatedSpanProps accepts SpringValue for style properties (like react-spring)
8+
type AnimatedSpanProps = Omit<React.ComponentProps<"span">, "style"> & {
9+
style?: React.CSSProperties & { width?: number | string | SpringValue<number> };
10+
};
11+
12+
// Simulates react-spring's animated component which accepts SpringValue in styles
13+
const animated = {
14+
span: React.forwardRef<HTMLSpanElement, AnimatedSpanProps>((props, ref) => (
15+
<span ref={ref} {...props} style={props.style as React.CSSProperties} />
16+
)),
17+
};
18+
19+
type AnimatedTextProps<C extends React.ElementType = "span"> = React.ComponentProps<C> & { as?: C };
20+
21+
function AnimatedText<C extends React.ElementType = "span">(props: AnimatedTextProps<C>) {
22+
const { as: Component = "span" as C, children, style, ...rest } = props;
23+
return (
24+
<Component {...rest} {...stylex.props(styles.animatedText)} style={style}>
25+
{children}
26+
</Component>
27+
);
28+
}
29+
30+
type Props = {
31+
width: number | SpringValue<number>;
32+
children: React.ReactNode;
33+
};
34+
35+
export function AnimatedNumber(props: Props) {
36+
const { width, children } = props;
37+
38+
// When width is a SpringValue, we need to use animated.span
39+
const isAnimated = typeof width !== "number";
40+
41+
if (isAnimated) {
42+
// This should work: animated.span accepts SpringValue<number> for width
43+
return (
44+
<AnimatedText as={animated.span} style={{ width }}>
45+
{children}
46+
</AnimatedText>
47+
);
48+
}
49+
return <AnimatedText style={{ width }}>{children}</AnimatedText>;
50+
}
51+
52+
export const App = () => <AnimatedNumber width={100}>42</AnimatedNumber>;
53+
54+
const styles = stylex.create({
55+
// When as={animated.span} is used, the component should render as animated.span
56+
// This pattern is common with animation libraries like react-spring
57+
animatedText: {
58+
fontVariantNumeric: "tabular-nums",
59+
overflow: "visible",
60+
display: "inline-flex",
61+
},
62+
});

test-cases/as-prop.input.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ const Button = styled.button`
1515

1616
// Pattern 2: styled(Component) where Component has custom props (like variant)
1717
// When used with as="label", the component's props must be preserved
18-
// Bug: Codemod converts <StyledText variant="small" as="label"> to <label variant="small">
19-
// but <label> doesn't accept variant - should keep using Text with as="label"
2018
const StyledText = styled(Text)`
2119
margin-top: 4px;
2220
`;
@@ -27,11 +25,12 @@ export const App = () => (
2725
<Button as="a" href="#">
2826
Link with Button styles
2927
</Button>
30-
{/* Pattern 2: styled(Component) with as prop - must preserve component's props */}
28+
{/* Pattern 2: styled(Component) with as prop */}
3129
<StyledText variant="small" color="muted">
3230
Normal styled text
3331
</StyledText>
34-
<StyledText variant="mini" as="label">
32+
{/* Pattern 3: as="label" with label-specific props like htmlFor */}
33+
<StyledText variant="mini" as="label" htmlFor="my-input">
3534
Label using Text styles
3635
</StyledText>
3736
</div>

test-cases/as-prop.output.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,38 @@ import * as stylex from "@stylexjs/stylex";
22
import * as React from "react";
33
import { Text } from "./lib/text";
44

5-
type StyledTextProps = React.ComponentProps<typeof Text> & { as?: React.ElementType };
5+
type ButtonProps = React.ComponentProps<"button"> & { as?: React.ElementType; href?: string };
66

7-
function StyledText(props: StyledTextProps) {
7+
function Button(props: ButtonProps) {
8+
const { as: Component = "button", children, style, ...rest } = props;
9+
return (
10+
<Component {...rest} {...stylex.props(styles.button)} style={style}>
11+
{children}
12+
</Component>
13+
);
14+
}
15+
16+
type StyledTextProps<C extends React.ElementType = typeof Text> = React.ComponentProps<
17+
typeof Text
18+
> &
19+
Omit<React.ComponentPropsWithoutRef<C>, keyof React.ComponentProps<typeof Text>> & { as?: C };
20+
21+
function StyledText<C extends React.ElementType = typeof Text>(props: StyledTextProps<C>) {
822
return <Text {...props} {...stylex.props(styles.text)} />;
923
}
1024

1125
export const App = () => (
1226
<div>
13-
<button {...stylex.props(styles.button)}>Normal Button</button>
14-
<a href="#" {...stylex.props(styles.button)}>
27+
<Button>Normal Button</Button>
28+
<Button as="a" href="#">
1529
Link with Button styles
16-
</a>
17-
{/* Pattern 2: styled(Component) with as prop - must preserve component's props */}
30+
</Button>
31+
{/* Pattern 2: styled(Component) with as prop */}
1832
<StyledText variant="small" color="muted">
1933
Normal styled text
2034
</StyledText>
21-
<StyledText variant="mini" as="label">
35+
{/* Pattern 3: as="label" with label-specific props like htmlFor */}
36+
<StyledText variant="mini" as="label" htmlFor="my-input">
2237
Label using Text styles
2338
</StyledText>
2439
</div>

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ const IconButtonInner = (props: IconButtonProps) => {
5151
};
5252

5353
export function IconButton(props: IconButtonProps) {
54-
return <IconButtonInner {...props} {...stylex.props(styles.iconButton)} />;
54+
const { $hoverStyles, style, ...rest } = props;
55+
return <IconButtonInner {...rest} {...stylex.props(styles.iconButton)} style={style} />;
5556
}
5657

5758
// Usage shows both interface properties and HTML attributes are needed

test-cases/forwarded-as.output.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import * as stylex from "@stylexjs/stylex";
44
type ButtonProps = React.ComponentProps<"button"> & { as?: React.ElementType; href?: string };
55

66
function Button(props: ButtonProps) {
7-
const { as: Component = "button", children, ...rest } = props;
7+
const { as: Component = "button", children, style, ...rest } = props;
88
return (
9-
<Component {...stylex.props(styles.button)} {...rest}>
9+
<Component {...rest} {...stylex.props(styles.button)} style={style}>
1010
{children}
1111
</Component>
1212
);

test-cases/transient-props.input.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ const Point = styled.div<{ $size?: number }>`
2424
background-color: white;
2525
`;
2626

27-
// Pattern 4: styled(Component) where base component REQUIRES the transient prop
28-
// The transient prop is used for styling AND needed by the base component
29-
// CollapseArrowIcon pattern - ArrowIcon needs $isOpen, and styled uses it too
27+
// Pattern 4: styled(Component) where base component declares the transient prop
28+
// The transient prop is used for styling by the wrapper
29+
// CollapseArrowIcon pattern - ArrowIcon declares $isOpen in props, wrapper uses it for styling
3030
import * as React from "react";
3131
import { Icon, type IconProps } from "./lib/icon";
3232

@@ -37,16 +37,17 @@ interface ArrowIconProps {
3737
}
3838

3939
function ArrowIcon(props: IconProps & ArrowIconProps) {
40+
const { $isOpen, ...rest } = props;
4041
return (
41-
<Icon {...props}>
42+
<Icon {...rest}>
4243
<svg viewBox="0 0 16 16">
4344
<path d="M7 10.6L10.8 7.6L7 5.4V10.6Z" />
4445
</svg>
4546
</Icon>
4647
);
4748
}
4849

49-
// The wrapper uses $isOpen for styling, but ArrowIcon also NEEDS $isOpen
50+
// The wrapper uses $isOpen for styling; ArrowIcon declares it in props but filters before spreading
5051
export const CollapseArrowIcon = styled(ArrowIcon)`
5152
transform: rotate(${(props) => (props.$isOpen ? "90deg" : "0deg")});
5253
transition: transform 0.2s;

test-cases/transient-props.output.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ const Link = ({ className, text, ...props }: { className?: string; text: string
66
</a>
77
);
88

9-
// Pattern 4: styled(Component) where base component REQUIRES the transient prop
10-
// The transient prop is used for styling AND needed by the base component
11-
// CollapseArrowIcon pattern - ArrowIcon needs $isOpen, and styled uses it too
9+
// Pattern 4: styled(Component) where base component declares the transient prop
10+
// The transient prop is used for styling by the wrapper
11+
// CollapseArrowIcon pattern - ArrowIcon declares $isOpen in props, wrapper uses it for styling
1212
import * as React from "react";
1313

1414
import { Icon, type IconProps } from "./lib/icon";
@@ -20,8 +20,9 @@ interface ArrowIconProps {
2020
}
2121

2222
function ArrowIcon(props: IconProps & ArrowIconProps) {
23+
const { $isOpen, ...rest } = props;
2324
return (
24-
<Icon {...props}>
25+
<Icon {...rest}>
2526
<svg viewBox="0 0 16 16">
2627
<path d="M7 10.6L10.8 7.6L7 5.4V10.6Z" />
2728
</svg>

0 commit comments

Comments
 (0)