Skip to content

Commit 895a367

Browse files
skovhuscursoragent
andauthored
feat(adapter): auto-detect sx-aware wrapped components, with optional override hook (#394)
* feat(adapter): wrappedComponentInterface to emit sx prop when re-styling sx-aware components Adds an optional adapter hook 'wrappedComponentInterface' that lets the codemod know an imported component already accepts a StyleX 'sx' prop. When wrapping such a component via styled(Component) and 'useSxProp' is enabled, the codemod emits 'sx={styles.x}' instead of '{...stylex.props(styles.x)}' and forwards className/style unchanged so the wrapped component handles merging itself. Co-authored-by: Kenneth Skovhus <skovhus@users.noreply.github.com> * fix(adapter): compose caller sx and destructure when wrapped component is sx-aware Addresses two P1 review comments on the wrappedComponentInterface feature: 1. Wrapper functions emitted for styled(SxAwareComponent) referenced an undeclared 'sx' identifier when the wrapper accepted external sx (allowSxProp). The wrapper now destructures 'sx' whenever the wrapped component is sx-aware OR the wrapper exposes external sx. 2. The emitted 'sx={...}' on the wrapped component appeared after the '{...props}' / '{...rest}' spread, silently overriding any caller-passed sx. The simple wrapper path, the destructure wrapper path, and the inlined-JSX rewrite path now all compose the caller's sx with the internal styles via 'sx={[styles.x, sx]}' so consumer sx values survive re-styling. Test coverage extends test-cases/wrapper-sxAware to cover an exported wrapper with external sx, an inlined call site receiving caller sx, and the wrapper function receiving caller sx. Also extracts a small isWrappedComponentSxAware helper so the lookup logic isn't duplicated between the wrapper-emitter and the JSX-rewrite step. Co-authored-by: Kenneth Skovhus <skovhus@users.noreply.github.com> * feat(adapter): auto-detect sx-aware wrapped components from prop type The codemod now scans an imported component's definition file and walks its declared prop type (intersections, type aliases, interfaces in the same file) to find an `sx?:` member. When detected, `styled(Component)` emits `<Component sx={styles.x} />` instead of `<Component {...stylex.props(styles.x)} />` automatically — no adapter configuration required. The existing `wrappedComponentInterface` adapter hook is kept as an explicit override for cases auto-detection cannot reach (typically package imports where the source isn't on disk, or components whose sx support is added by a HOC at runtime). It now wins over auto-detection only when it returns a defined value; `undefined` falls through to the auto-detector. Adds unit tests for the detection helper covering: - inline literal prop type - generic component using `Type<C> = TextProps & Omit<…> & { sx?: … }` (the canonical real-world pattern) - interface-typed props - negative case (no sx member) - package imports (out of reach) - all three adapter override outcomes Extends the wrapper-sxAware fixture with a Text-style generic component to lock in the type-alias intersection path end-to-end. Co-authored-by: Kenneth Skovhus <skovhus@users.noreply.github.com> * test: lock in Omit<…, "sx"> and value-position negatives for sx-aware detection Adds two regression tests proving the auto-detector returns false when: 1. The component's prop type explicitly omits sx via Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "sx"> 2. "sx" appears as part of a string-literal value of an unrelated prop (e.g. `variant?: "sx-like" | "other"`) Both already pass with the current walker because: - Type parameter lists of opaque utility types (Omit/Pick/etc) are not descended into; only declared aliases/interfaces in the same file are resolved. - Detection only inspects literal/interface member *keys*, never value positions, so substring matches in string literals can't trigger a false positive. Co-authored-by: Kenneth Skovhus <skovhus@users.noreply.github.com> * test: comprehensive unit-test coverage for sx-aware auto-detection Expands the wrapped-component-interface test suite to 43 cases organised by intent, documenting both supported patterns and known limitations of the static walker. Sections: - Positive: prop type signatures * function declarations / arrow / function expression * required vs optional sx * string-literal property keys * any-typed sx values * default exports * non-exported declarations (the consumer's import is what makes it 'exported' for the codemod's purposes) - Positive: type alias / interface resolution * single alias hop, intersection (Text-style generic component), nested chain, interface * union types where one branch carries sx * parenthesised types * cyclic aliases (no infinite loop) - Negative: sx absent or out of reach * plain components, Omit<…, "sx">, Pick utility narrowing * sx as a string literal value of an unrelated prop * sx mentioned only in a comment * components with no parameters / no parameter type annotation * sx on a sibling component * unknown local name * package-style imports (no source path on disk) - Negative: documented walker limitations * React.FC<Props> generic on the variable annotation * forwardRef HOC wrapper * interface 'extends' clauses * type imports across files - File-system edge cases * missing file, empty file, syntax error (no throw) * extension probing for bare absolute paths - Adapter override semantics * true / false / undefined fallthrough * package-import override path * useSxProp disabled * missing or empty importMap - Caching * repeat lookups stay consistent * (file, componentName) keying — different components in same file do not poison each other Co-authored-by: Kenneth Skovhus <skovhus@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 5eacafa commit 895a367

18 files changed

Lines changed: 1519 additions & 12 deletions

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ const adapter = defineAdapter({
3636
styleMerger: null,
3737
// Emit sx={} JSX attributes instead of {...stylex.props()} spreads (requires StyleX ≥0.18)
3838
useSxProp: false,
39+
// Optional override for sx-aware wrapped components. Auto-detection is on by
40+
// default when `useSxProp: true` — the codemod scans the imported component's
41+
// prop type for an `sx?:` member. Use this hook to override (e.g. for package
42+
// imports that auto-detection can't reach).
43+
wrappedComponentInterface(ctx) {
44+
return undefined;
45+
},
3946
// Optional: customize the runtime theme hook import/call used for theme conditionals
4047
// Defaults to { functionName: "useTheme", importSource: { kind: "specifier", value: "styled-components" } }
4148
themeHook: {
@@ -172,6 +179,28 @@ const adapter = defineAdapter({
172179
*/
173180
useSxProp: false,
174181

182+
/**
183+
* Optional override for sx-aware wrapped components.
184+
*
185+
* When `useSxProp: true`, the codemod auto-detects whether an imported
186+
* component accepts an `sx` prop by walking its declared prop type
187+
* (intersections, type aliases, and interfaces in the same file). When
188+
* `styled(Component)` wraps an sx-aware component, the codemod emits
189+
* `<Component sx={styles.x} />` instead of `<Component {...stylex.props(styles.x)} />`
190+
* and lets the wrapped component merge className/style itself.
191+
*
192+
* Use this hook to override auto-detection for cases it can't see — typically
193+
* package imports (where the source isn't on disk) or components whose sx
194+
* support is added by a HOC at runtime. Returning `undefined` falls through
195+
* to auto-detection.
196+
*/
197+
wrappedComponentInterface(ctx) {
198+
if (ctx.importSource.startsWith("@company/ui/")) {
199+
return { acceptsSx: true };
200+
}
201+
return undefined;
202+
},
203+
175204
/**
176205
* Optional: customize the runtime theme hook used when wrappers need theme booleans.
177206
* Defaults to useTheme from styled-components.
@@ -203,6 +232,7 @@ Adapters are the main extension point, see full example above. They let you cont
203232
- how helper calls are resolved (via `resolveCall({ ... })` returning `{ expr, imports }`, or `{ preserveRuntimeCall: true }` to keep only the original helper runtime call; `null`/`undefined` bails the file)
204233
- which exported components should support external className/style extension and/or polymorphic `as` prop (`externalInterface`)
205234
- how className/style merging is handled for components accepting external styling (`styleMerger`)
235+
- which imported components already accept a StyleX `sx` prop (auto-detected from the imported component's prop type when `useSxProp: true`; can be overridden via `wrappedComponentInterface`). When detected, the codemod emits `sx={styles.x}` on the wrapped component instead of `{...stylex.props(styles.x)}`.
206236
- which runtime theme hook import/call to use for emitted wrapper theme conditionals (`themeHook`)
207237
- how `styled(ImportedComponent)` wrapping an external base component can be inlined into an intrinsic element with static StyleX styles (`resolveBaseComponent`)
208238

src/__tests__/extract-external-interface.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,6 +1134,24 @@ describe("runPrepass createExternalInterface snapshot on test-cases", () => {
11341134
"style": false,
11351135
"styles": true,
11361136
},
1137+
"test-cases/lib/sx-aware-component.tsx:SxAwareButton": {
1138+
"as": false,
1139+
"className": false,
1140+
"elementProps": true,
1141+
"ref": false,
1142+
"spreadProps": true,
1143+
"style": false,
1144+
"styles": true,
1145+
},
1146+
"test-cases/lib/sx-aware-text.tsx:Text": {
1147+
"as": false,
1148+
"className": false,
1149+
"elementProps": true,
1150+
"ref": false,
1151+
"spreadProps": true,
1152+
"style": false,
1153+
"styles": true,
1154+
},
11371155
"test-cases/lib/text.ts:Text": {
11381156
"as": false,
11391157
"className": false,

src/__tests__/fixture-adapters.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const fixtureAdapter = defineAdapter({
5656
"basic-jsdocExported",
5757
"htmlProp-element",
5858
"wrapper-mergerImported",
59+
"wrapper-sxAware",
5960
"htmlProp-input",
6061
"transientProp-notForwarded",
6162
"inlineBase-booleanVariantKey",

src/adapter.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,47 @@ export interface MarkerFileContext {
573573
filePath: string;
574574
}
575575

576+
// ────────────────────────────────────────────────────────────────────────────
577+
// Wrapped Component Interface
578+
// ────────────────────────────────────────────────────────────────────────────
579+
580+
/**
581+
* Context for `adapter.wrappedComponentInterface(...)`.
582+
*
583+
* Called for each `styled(Component)` declaration where `Component` is
584+
* imported from another module. Lets the adapter declare that the wrapped
585+
* component already accepts a StyleX `sx` prop so the codemod can emit
586+
* `sx={style}` instead of `{...stylex.props(style)}`.
587+
*/
588+
export interface WrappedComponentInterfaceContext {
589+
/**
590+
* Import source for the wrapped base component.
591+
* - package import: e.g. `"@company/ui"`
592+
* - relative import: resolved absolute path
593+
*/
594+
importSource: string;
595+
/**
596+
* Imported binding name for the wrapped base component.
597+
* Example: `import { Button as UiButton } ...` -> importedName: "Button"
598+
*/
599+
importedName: string;
600+
/**
601+
* Absolute path of the file currently being transformed.
602+
*/
603+
filePath: string;
604+
}
605+
606+
/**
607+
* Result for `adapter.wrappedComponentInterface(...)`.
608+
*
609+
* - `acceptsSx: true` — the wrapped component accepts an `sx` prop. The codemod
610+
* emits `sx={style}` instead of `{...stylex.props(style)}` and skips
611+
* className/style merging in the wrapper (the wrapped component owns that).
612+
*/
613+
export interface WrappedComponentInterfaceResult {
614+
acceptsSx: boolean;
615+
}
616+
576617
// ────────────────────────────────────────────────────────────────────────────
577618
// Style Merger Configuration
578619
// ────────────────────────────────────────────────────────────────────────────
@@ -763,6 +804,31 @@ export interface Adapter {
763804
*/
764805
usePhysicalProperties?: boolean;
765806

807+
/**
808+
* Optional override for sx-aware wrapped component detection.
809+
*
810+
* When `useSxProp: true`, the codemod auto-detects whether an imported
811+
* component accepts a StyleX `sx` prop by reading its definition file and
812+
* walking its declared prop type (intersections, type aliases, interfaces
813+
* in the same file). When detected, `styled(Component)` emits
814+
* `<Component sx={styles.x} />` instead of
815+
* `<Component {...stylex.props(styles.x)} />`.
816+
*
817+
* Use this hook to override auto-detection for cases it cannot see — most
818+
* commonly package imports (e.g. `@company/ui`) where the source isn't on
819+
* disk, or components whose sx support is added by a HOC at runtime.
820+
*
821+
* Return:
822+
* - `{ acceptsSx: true }` to force the `sx={...}` path
823+
* - `{ acceptsSx: false }` to force the `{...stylex.props(...)}` path
824+
* - `undefined` to fall through to auto-detection (default)
825+
*
826+
* Only consulted for `styled(ImportedComponent)` declarations.
827+
*/
828+
wrappedComponentInterface?: (
829+
context: WrappedComponentInterfaceContext,
830+
) => WrappedComponentInterfaceResult | undefined;
831+
766832
/**
767833
* Optional function to customize where marker sidecar files (`stylex.defineMarker()`)
768834
* are written. By default, markers are placed in a `.stylex.ts` file next to the source.
@@ -818,6 +884,7 @@ export interface AdapterInput {
818884
themeHook?: Adapter["themeHook"];
819885
useSxProp: Adapter["useSxProp"];
820886
usePhysicalProperties?: Adapter["usePhysicalProperties"];
887+
wrappedComponentInterface?: Adapter["wrappedComponentInterface"];
821888
markerFile?: Adapter["markerFile"];
822889
}
823890

src/internal/emit-wrappers.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
* Core concepts: intrinsic vs component wrappers and insertion ordering.
44
*/
55
import type { ASTNode, Collection, JSCodeshift, Property } from "jscodeshift";
6-
import { DEFAULT_THEME_HOOK, type StyleMergerConfig, type ThemeHookConfig } from "../adapter.js";
6+
import {
7+
DEFAULT_THEME_HOOK,
8+
type ImportSource,
9+
type StyleMergerConfig,
10+
type ThemeHookConfig,
11+
type WrappedComponentInterfaceResult,
12+
} from "../adapter.js";
713
import type { StyledDecl } from "./transform-types.js";
814
import { emitComponentWrappers } from "./emit-wrappers/emit-component.js";
915
import { emitIntrinsicWrappers } from "./emit-wrappers/emit-intrinsic.js";
@@ -28,6 +34,12 @@ export function emitWrappers(args: {
2834
siblingMarkerKeys?: Set<string>;
2935
parentsNeedingDefaultMarker?: Set<string>;
3036
useSxProp: boolean;
37+
importMap?: Map<string, { importedName: string; source: ImportSource }>;
38+
wrappedComponentInterface?: (ctx: {
39+
importSource: string;
40+
importedName: string;
41+
filePath: string;
42+
}) => WrappedComponentInterfaceResult | undefined;
3143
}): void {
3244
const {
3345
root,
@@ -46,6 +58,8 @@ export function emitWrappers(args: {
4658
siblingMarkerKeys,
4759
parentsNeedingDefaultMarker,
4860
useSxProp,
61+
importMap,
62+
wrappedComponentInterface,
4963
} = args;
5064

5165
const wrapperDecls = styledDecls.filter((d) => d.needsWrapperComponent && !d.isCssHelper);
@@ -70,6 +84,8 @@ export function emitWrappers(args: {
7084
siblingMarkerKeys,
7185
parentsNeedingDefaultMarker,
7286
useSxProp,
87+
importMap,
88+
wrappedComponentInterface,
7389
});
7490

7591
const emitted: ASTNode[] = [];

src/internal/emit-wrappers/emit-component.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,12 @@ export function emitComponentWrappers(emitter: WrapperEmitter): {
145145
const allowSxProp = emitter.shouldAllowSxProp(d);
146146
const allowClassNameProp = shouldAllowClassName || wrappedHasClassName;
147147
const allowStyleProp = shouldAllowStyle || wrappedHasStyle;
148+
// When the wrapped component accepts a StyleX `sx` prop (per adapter), the
149+
// wrapper passes className/style through unchanged via `{...rest}` and the
150+
// wrapped component merges them with its `sx` itself. The wrapper still
151+
// accepts className/style in its type, but does not destructure them.
152+
const wrappedAcceptsSx =
153+
emitter.useSxProp && emitter.wrappedComponentAcceptsSxProp(wrappedComponent);
148154
// When the wrapped component has className/style as REQUIRED props, we must
149155
// force them to be optional in the wrapper's type. Otherwise, the wrapper would
150156
// inherit the requiredness, breaking call sites that don't pass className/style
@@ -780,7 +786,11 @@ export function emitComponentWrappers(emitter: WrapperEmitter): {
780786
const forwardedAsId = j.identifier("forwardedAs");
781787
const wrappedComponentExpr = buildWrappedComponentExpr();
782788

783-
if (allowSxProp) {
789+
// When the wrapped component accepts a StyleX `sx` prop, callers may also
790+
// pass `sx` even when the wrapper does not declare it externally. Destructure
791+
// it so we can compose with internal styles instead of letting `{...rest}`
792+
// forward it (which would be overwritten by the wrapper's own `sx={...}`).
793+
if (allowSxProp || wrappedAcceptsSx) {
784794
styleArgs.push(sxId);
785795
}
786796

@@ -792,6 +802,13 @@ export function emitComponentWrappers(emitter: WrapperEmitter): {
792802
}
793803
}
794804

805+
// When the wrapped component is sx-aware, className/style flow through
806+
// `{...rest}` unchanged — the wrapped component merges them with its
807+
// internal styles itself. `sx` is destructured so the wrapper can compose
808+
// it with its own internal styles via `sx={[styles.x, sx]}`.
809+
const destructureClassName = allowClassNameProp && !wrappedAcceptsSx;
810+
const destructureStyle = allowStyleProp && !wrappedAcceptsSx;
811+
const destructureSx = allowSxProp || wrappedAcceptsSx;
795812
const patternProps = emitter.buildDestructurePatternProps({
796813
baseProps: [
797814
...(isPolymorphicComponentWrapper
@@ -803,10 +820,10 @@ export function emitComponentWrappers(emitter: WrapperEmitter): {
803820
) as Property,
804821
]
805822
: []),
806-
...(allowClassNameProp ? [patternProp("className", classNameId)] : []),
823+
...(destructureClassName ? [patternProp("className", classNameId)] : []),
807824
...(includeChildren ? [patternProp("children", childrenId)] : []),
808-
...(allowStyleProp ? [patternProp("style", styleId)] : []),
809-
...(allowSxProp ? [patternProp("sx", sxId)] : []),
825+
...(destructureStyle ? [patternProp("style", styleId)] : []),
826+
...(destructureSx ? [patternProp("sx", sxId)] : []),
810827
...((d.supportsRefProp ?? false) ? [patternProp("ref", refId)] : []),
811828
...(shouldLowerForwardedAs ? [patternProp("forwardedAs", forwardedAsId)] : []),
812829
],
@@ -833,6 +850,7 @@ export function emitComponentWrappers(emitter: WrapperEmitter): {
833850
inlineStyleProps: (d.inlineStyleProps ?? []) as InlineStyleProp[],
834851
staticClassNameExpr,
835852
isIntrinsicElement: false,
853+
wrappedAcceptsSxProp: wrappedAcceptsSx,
836854
});
837855

838856
const stmts: StatementKind[] = [declStmt];
@@ -1027,7 +1045,21 @@ export function emitComponentWrappers(emitter: WrapperEmitter): {
10271045
openingAttrs.push(
10281046
...emitter.buildStaticAttrsFromRecord(staticAttrs, { booleanTrueAsShorthand: false }),
10291047
);
1030-
openingAttrs.push(j.jsxSpreadAttribute(stylexPropsCall));
1048+
// When the wrapped component accepts a StyleX `sx` prop, emit `sx={...}`
1049+
// instead of `{...stylex.props(...)}` so the wrapped component can merge it
1050+
// with className/style it receives from `{...props}`. The caller's `sx` (if
1051+
// any) is composed in by appending `props.sx` to the array — the spread
1052+
// above would otherwise be overwritten by this `sx` attribute.
1053+
if (wrappedAcceptsSx) {
1054+
const composedStyleArgs: ExpressionKind[] = [
1055+
...styleArgs,
1056+
j.memberExpression(propsId, j.identifier("sx")),
1057+
];
1058+
const sxExpr = j.arrayExpression(composedStyleArgs);
1059+
openingAttrs.push(j.jsxAttribute(j.jsxIdentifier("sx"), j.jsxExpressionContainer(sxExpr)));
1060+
} else {
1061+
openingAttrs.push(j.jsxSpreadAttribute(stylexPropsCall));
1062+
}
10311063

10321064
const jsx = emitter.buildJsxElement({
10331065
tagName: jsxTagName,

src/internal/emit-wrappers/style-merger.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ export function emitStyleMerging(args: {
8787
/** Set to true when the rendered tag is an intrinsic HTML element (lowercase).
8888
* The sx prop is only valid on intrinsic elements (processed by the StyleX babel plugin). */
8989
isIntrinsicElement?: boolean;
90+
/** Set to true when the rendered (non-intrinsic) component already accepts a
91+
* StyleX `sx` prop. Enables the `sx={...}` fast path for component wrappers. */
92+
wrappedAcceptsSxProp?: boolean;
9093
}): StyleMergingResult {
9194
const {
9295
j,
@@ -100,6 +103,7 @@ export function emitStyleMerging(args: {
100103
inlineStyleProps = [],
101104
staticClassNameExpr,
102105
isIntrinsicElement = true,
106+
wrappedAcceptsSxProp = false,
103107
} = args;
104108

105109
const {
@@ -166,6 +170,24 @@ export function emitStyleMerging(args: {
166170
});
167171
}
168172

173+
// When the wrapped component accepts a StyleX `sx` prop (per adapter), emit
174+
// `sx={...}` directly and skip className/style merging — the wrapped component
175+
// handles className/style itself. The destructured className/style values are
176+
// forwarded to the wrapped component via the surrounding `{...rest}` spread.
177+
if (wrappedAcceptsSxProp && inlineStyleProps.length === 0 && !staticClassNameExpr) {
178+
const sxExpr =
179+
styleArgs.length === 1 && styleArgs[0] ? styleArgs[0] : j.arrayExpression(styleArgs);
180+
return {
181+
needsSxVar: false,
182+
sxDecl: null,
183+
jsxSpreadExpr: null,
184+
sxPropExpr: sxExpr,
185+
classNameAttr: null,
186+
classNameBeforeSpread: false,
187+
styleAttr: null,
188+
};
189+
}
190+
169191
// If neither className nor style merging is needed, just use stylex.props directly
170192
if (
171193
!allowClassNameProp &&

0 commit comments

Comments
 (0)