diff --git a/.changeset/nine-apes-remain.md b/.changeset/nine-apes-remain.md
new file mode 100644
index 0000000000..ca670ffb6d
--- /dev/null
+++ b/.changeset/nine-apes-remain.md
@@ -0,0 +1,5 @@
+---
+"@heroui/system-rsc": patch
+---
+
+fix(system-rsc): correct type inference in extendVariants and CompoundVariants & correct slot detection in getSlots() to ensure proper slot key extraction and consistent compoundVariants behavior.
diff --git a/packages/core/system-rsc/__tests__/extend-variants.test.tsx b/packages/core/system-rsc/__tests__/extend-variants.test.tsx
index 9d3979deb4..3d5ce5f41d 100644
--- a/packages/core/system-rsc/__tests__/extend-variants.test.tsx
+++ b/packages/core/system-rsc/__tests__/extend-variants.test.tsx
@@ -1,4 +1,4 @@
-import type {ExtendVariantProps} from "../src/extend-variants";
+import type {ExtendVariantProps, ExtendVariantWithSlotsProps} from "../src/extend-variants";
import React from "react";
import {render, screen} from "@testing-library/react";
@@ -6,6 +6,7 @@ import {render, screen} from "@testing-library/react";
import {extendVariants} from "../src/extend-variants";
import {Button} from "../test-utils/extend-components";
import {Card} from "../test-utils/slots-component";
+import {Link} from "../../react/src";
const createExtendNoSlotsComponent = (styles: ExtendVariantProps = {}) =>
extendVariants(Button, {
@@ -37,9 +38,14 @@ const createExtendNoSlotsComponent = (styles: ExtendVariantProps = {}) =>
],
});
-const createExtendSlotsComponent = () =>
+const createExtendSlotsComponent = (styles: ExtendVariantWithSlotsProps = {}) =>
extendVariants(Card, {
variants: {
+ variant: {
+ flat: "",
+ filled: "",
+ test: "",
+ },
shadow: {
none: {
base: "shadow-xs",
@@ -73,6 +79,19 @@ const createExtendSlotsComponent = () =>
shadow: "xl",
radius: "xl",
},
+ compoundVariants: styles?.compoundVariants ?? [
+ {
+ shadow: "none",
+ radius: "none",
+ class: "rounded-sm",
+ },
+ {
+ shadow: "none",
+ class: {
+ header: "scale-75",
+ },
+ },
+ ],
});
describe("extendVariants function - no slots", () => {
@@ -91,14 +110,33 @@ describe("extendVariants function - no slots", () => {
expect(ref.current).not.toBeNull();
});
- test("should render with given text", () => {
+ test("as Link should work", () => {
const Button2 = createExtendNoSlotsComponent();
- render(Press me);
+ const {container} = render(
+
+ Press me
+ ,
+ );
+
+ // Link component from react package - verify it renders
+ const link = container.querySelector("a");
+ expect(link).toBeInTheDocument();
expect(screen.getByText("Press me")).toBeInTheDocument();
});
+ test("should render with given text", () => {
+ const Button2 = createExtendNoSlotsComponent();
+ const {container} = render(
+ Press me,
+ );
+
+ const button = container.querySelector("button");
+
+ expect(button).toHaveTextContent("Press me");
+ });
+
test("should override the base styles", () => {
const Button2 = createExtendNoSlotsComponent();
const {container} = render(
@@ -168,6 +206,78 @@ describe("extendVariants function - no slots", () => {
expect(button).toHaveClass("scale-150");
});
+ test("as prop should change rendered element to anchor", () => {
+ const Button2 = createExtendNoSlotsComponent();
+ const {container} = render(
+
+ Link Button
+ ,
+ );
+
+ const link = container.querySelector("a");
+
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute("href", "/test");
+ expect(link).toHaveTextContent("Link Button");
+ });
+
+ test("as prop should change rendered element to div", () => {
+ const Button2 = createExtendNoSlotsComponent();
+ const {container} = render(Div Button);
+
+ const div = container.querySelector("div");
+
+ expect(div).toBeInTheDocument();
+ expect(div).toHaveTextContent("Div Button");
+
+ // Should not be a button
+ const button = container.querySelector("button");
+
+ expect(button).not.toBeInTheDocument();
+ });
+
+ test("ref should work with polymorphic component as anchor", () => {
+ const ref = React.createRef();
+ const Button2 = createExtendNoSlotsComponent();
+
+ render(
+
+ Link
+ ,
+ );
+
+ expect(ref.current).toBeInstanceOf(HTMLAnchorElement);
+ expect(ref.current).toHaveAttribute("href", "/test");
+ });
+
+ test("variant styles should persist with 'as' prop", () => {
+ const Button2 = createExtendNoSlotsComponent();
+ const {container} = render(
+
+ Link
+ ,
+ );
+
+ const link = container.querySelector("a");
+
+ expect(link).toHaveClass("size--2xl");
+ // isScalable=true triggers scale-125, but compound variant for size=2xl + isScalable overrides to scale-150
+ expect(link).toHaveClass("scale-150");
+ });
+
+ test("compound variant styles should work with 'as' prop", () => {
+ const Button2 = createExtendNoSlotsComponent();
+ const {container} = render(
+
+ Link
+ ,
+ );
+
+ const link = container.querySelector("a");
+
+ expect(link).toHaveClass("scale-150"); // compound variant for size="2xl" + isScalable
+ });
+
test("should respect defaultVariants.className", () => {
const Button2 = extendVariants(Button, {
defaultVariants: {
@@ -291,6 +401,52 @@ describe("extendVariants function - with slots", () => {
expect(headerEl).toHaveClass("rounded-none");
});
+ test("should include the compound variant styles - extended", () => {
+ const Card2 = createExtendSlotsComponent();
+
+ const {getByTestId} = render(
+
+ Card Content
+ ,
+ );
+
+ const baseEl = getByTestId("base");
+ const headerEl = getByTestId("header");
+
+ expect(baseEl).toHaveClass("rounded-sm");
+ expect(headerEl).toHaveClass("scale-75");
+ });
+
+ test("should include the compound variant styles - original", () => {
+ const Card2 = createExtendSlotsComponent({
+ compoundVariants: [
+ {
+ shadow: "none",
+ radius: "sm",
+ class: "rounded-xl",
+ },
+ {
+ radius: "sm",
+ class: {
+ header: "scale-150",
+ },
+ },
+ ],
+ });
+
+ const {getByTestId} = render(
+
+ Card Content
+ ,
+ );
+
+ const baseEl = getByTestId("base");
+ const headerEl = getByTestId("header");
+
+ expect(baseEl).toHaveClass("rounded-xl");
+ expect(headerEl).toHaveClass("scale-150");
+ });
+
test("should override base component slots with direct slots option", () => {
const Card2 = extendVariants(Card, {
slots: {
diff --git a/packages/core/system-rsc/src/extend-variants.d.ts b/packages/core/system-rsc/src/extend-variants.d.ts
index 2dceadae6d..53c8e4d60f 100644
--- a/packages/core/system-rsc/src/extend-variants.d.ts
+++ b/packages/core/system-rsc/src/extend-variants.d.ts
@@ -1,17 +1,13 @@
import type {ClassValue, StringToBoolean, OmitUndefined, ClassProp} from "tailwind-variants";
-import type {
- ForwardRefExoticComponent,
- JSXElementConstructor,
- PropsWithoutRef,
- RefAttributes,
-} from "react";
+import type {ForwardRefExoticComponent, JSXElementConstructor, RefAttributes} from "react";
+import type {InternalForwardRefRenderFunction} from "./types.js";
type SlotsClassValue = {
[K in keyof S]?: ClassValue;
};
type Variants = {
- [K: string]: {[P: string]: S extends undefined ? ClassValue : SlotsClassValue};
+ [K: string]: {[P: string]: GetSuggestedValues};
};
type ComponentProps = C extends JSXElementConstructor ? P : never;
@@ -20,16 +16,16 @@ type ComponentSlots = CP extends {classNames?: infer S} ? S : undefined;
type ValidateSubtype = OmitUndefined extends U ? "true" : "false";
-type GetSuggestedValues = S extends undefined ? ClassValue : SlotsClassValue;
+type GetSuggestedValues = ClassValue | (S extends undefined ? never : SlotsClassValue);
type SuggestedVariants = {
- [K in keyof CP]?: ValidateSubtype extends "true"
+ [K in Exclude]?: ValidateSubtype<
+ CP[K],
+ string
+ > extends "true"
? {[K2 in CP[K]]?: GetSuggestedValues}
: ValidateSubtype extends "true"
- ? {
- true?: GetSuggestedValues;
- false?: GetSuggestedValues;
- }
+ ? {true?: GetSuggestedValues; false?: GetSuggestedValues}
: never;
};
@@ -46,9 +42,11 @@ type VariantValue = {
: never);
};
-type DefaultVariants = VariantValue;
+type DefaultVariants = VariantValue & {
+ classNames?: S extends undefined ? never : SlotsClassValue;
+};
-type CompoundVariants = Array & ClassProp>;
+type CompoundVariants = Array & ClassProp>>;
type Options = {
/**
@@ -73,7 +71,7 @@ export type ExtendVariantProps = {
export type ExtendVariantWithSlotsProps = {
variants?: Record>>;
- defaultVariants?: Record;
+ defaultVariants?: Record>;
compoundVariants?: Array>>;
};
@@ -84,33 +82,35 @@ type InferRef =
? I
: any;
-export type ExtendVariants = {
- <
- C extends JSXElementConstructor,
- CP extends ComponentProps,
- S extends ComponentSlots,
- V extends ComposeVariants,
- SV extends SuggestedVariants,
- DV extends DefaultVariants,
- CV extends CompoundVariants,
- >(
- BaseComponent: C,
- styles: {
- variants?: V;
- defaultVariants?: DV;
- compoundVariants?: CV;
- slots?: S;
- },
- opts?: Options,
- ): ForwardRefExoticComponent<
- PropsWithoutRef<
- CP & {
- [key in keyof V]?: StringToBoolean;
- }
- > &
- RefAttributes>
- >;
-};
+export type ExtendVariants = <
+ C extends keyof JSX.IntrinsicElements | JSXElementConstructor,
+ CP extends ComponentProps = ComponentProps,
+ S extends ComponentSlots = ComponentSlots,
+ V extends ComposeVariants = ComposeVariants,
+ SV extends SuggestedVariants = SuggestedVariants,
+ DV extends DefaultVariants = DefaultVariants,
+ CV extends CompoundVariants> = CompoundVariants<
+ V,
+ SV,
+ ComponentSlots
+ >,
+>(
+ BaseComponent: C,
+ styles: {
+ variants?: V;
+ defaultVariants?: DV;
+ compoundVariants?: CV;
+ slots?: S;
+ },
+ opts?: Options,
+) => InternalForwardRefRenderFunction<
+ C,
+ {
+ [K in Exclude]?:
+ | (K extends keyof CP ? CP[K] : never)
+ | (K extends keyof V ? StringToBoolean> : never);
+ }
+>;
// main function
export declare const extendVariants: ExtendVariants;
diff --git a/packages/core/system-rsc/src/extend-variants.js b/packages/core/system-rsc/src/extend-variants.js
index 3d11be1c35..b9e43ae9ac 100644
--- a/packages/core/system-rsc/src/extend-variants.js
+++ b/packages/core/system-rsc/src/extend-variants.js
@@ -3,22 +3,38 @@ import {tv, cn} from "@heroui/theme";
import {mapPropsVariants} from "./utils";
+/**
+ * Extracts slot names from variant configurations.
+ * Traverses: variants -> variant groups -> variant configs -> slot names
+ * @param {Object} variants - Nested object: { variantName: { value: { slotName: "...", ... } } }
+ * @returns {Object} Map of slot names to empty strings
+ */
function getSlots(variants) {
- return variants
- ? Object.values(variants)
- .flatMap(Object.values)
- .reduce((acc, slot) => {
- if (typeof slot === "object" && slot !== null && !(slot instanceof String)) {
- Object.keys(slot).forEach((key) => {
- if (!acc.hasOwnProperty(key)) {
- acc[key] = "";
- }
- });
- }
-
- return acc;
- }, {})
- : {};
+ if (!variants || typeof variants !== "object") return {};
+
+ const acc = Object.create(null);
+
+ for (const group of Object.values(variants)) {
+ if (!group || typeof group !== "object") continue;
+
+ for (const config of Object.values(group)) {
+ // Skip non-objects, arrays (which would yield numeric indices), and String objects
+ if (
+ !config ||
+ typeof config !== "object" ||
+ Array.isArray(config) ||
+ config instanceof String
+ ) {
+ continue;
+ }
+
+ for (const slotName of Object.keys(config)) {
+ acc[slotName] = "";
+ }
+ }
+ }
+
+ return acc;
}
function getClassNamesWithProps({
@@ -114,22 +130,25 @@ export function extendVariants(BaseComponent, styles = {}, opts = {}) {
const hasSlots = typeof slots === "object" && Object.keys(slots).length !== 0;
const ForwardedComponent = React.forwardRef((originalProps = {}, ref) => {
+ // Extract 'as' prop if present
+ const {as: Component = BaseComponent, ...restProps} = originalProps;
+
const newProps = React.useMemo(() =>
getClassNamesWithProps(
{
slots,
variants,
compoundVariants,
- props: originalProps,
+ props: restProps, // Use restProps without 'as'
defaultVariants,
hasSlots,
opts,
},
- [originalProps],
+ [restProps],
),
);
- return React.createElement(BaseComponent, {...originalProps, ...newProps, ref});
+ return React.createElement(Component, {...restProps, ...newProps, ref});
});
// Add collection node function for collection-based components
diff --git a/packages/core/system-rsc/src/types.ts b/packages/core/system-rsc/src/types.ts
index a2534abf1c..1bdafa4779 100644
--- a/packages/core/system-rsc/src/types.ts
+++ b/packages/core/system-rsc/src/types.ts
@@ -40,7 +40,7 @@ export type MergeWithAs<
AsComponent extends As = As,
> = (RightJoinProps | RightJoinProps) & {
as?: AsComponent;
-};
+} & React.RefAttributes;
export type InternalForwardRefRenderFunction<
Component extends As,