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
5 changes: 5 additions & 0 deletions .changeset/rsc-lazy-icon.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/kumo": patch
---

`Button`, `LinkButton`, `Badge`, `DropdownMenu` items, `Sidebar.MenuButton` and `InputGroup.Button` no longer crash with React error #306 when `icon` is an element passed from a React Server Component. React can deliver such an element as a lazy reference, which Kumo now unwraps before deciding whether to render `icon` as an element or a component (#891).
6 changes: 6 additions & 0 deletions packages/kumo/ai/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ import { PlusIcon, TrashIcon, GearIcon } from "@phosphor-icons/react";
<DropdownMenu.Item icon={TrashIcon} variant="danger">Delete</DropdownMenu.Item>
```

`icon` props also accept a rendered element, including one passed from a React Server Component:

```tsx
<Button icon={<PlusIcon weight="bold" />}>Add Worker</Button>
```

## Controlled State Reference

| Component | Value Prop | Change Callback | Default Prop |
Expand Down
17 changes: 17 additions & 0 deletions packages/kumo/src/components/badge/badge.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { version, type ReactElement, type ReactNode } from "react";
import { describe, expect, it, vi } from "vite-plus/test";
import { render, screen } from "@testing-library/react";
import { ArrowRightIcon } from "@phosphor-icons/react";
Expand Down Expand Up @@ -38,6 +39,22 @@ describe("Badge", () => {
expect(screen.getByText("Next").querySelector("svg")).toBeTruthy();
});

// React Flight (RSC) can deliver an element prop as a lazy reference whose
// resolved value is an element, not a component (#891).
it.skipIf(version.startsWith("18."))(
"renders an icon element delivered as an RSC lazy reference",
() => {
const lazyElement = {
$$typeof: Symbol.for("react.lazy"),
_payload: <ArrowRightIcon />,
_init: (payload: ReactElement) => payload,
} as unknown as ReactNode;

render(<Badge icon={lazyElement}>Next</Badge>);
expect(screen.getByText("Next").querySelector("svg")).toBeTruthy();
},
);

describe("filled appearance (default)", () => {
it("applies variant classes for filled badges", () => {
render(<Badge variant="error">Error</Badge>);
Expand Down
4 changes: 3 additions & 1 deletion packages/kumo/src/components/badge/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { type ReactNode } from "react";
import type { Icon } from "@phosphor-icons/react";
import { cn } from "../../utils/cn";
import { resolveVariant } from "../../utils/resolve-variant";
import { unwrapLazyElement } from "../../utils/unwrap-lazy-element";

/** Base styles applied to all badge variants. */
export const KUMO_BADGE_BASE_STYLES =
Expand Down Expand Up @@ -163,7 +164,8 @@ export function badgeVariants({
// Legacy type alias for backwards compatibility
export type BadgeVariant = KumoBadgeVariant;

const renderIconNode = (IconComponent?: Icon | ReactNode) => {
const renderIconNode = (iconProp?: Icon | ReactNode) => {
const IconComponent = unwrapLazyElement(iconProp);
if (!IconComponent) return null;
const Component = IconComponent as React.ComponentType<
Record<string, unknown>
Expand Down
31 changes: 30 additions & 1 deletion packages/kumo/src/components/button/button.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from "react";
import { describe, expect, it } from "vite-plus/test";
import { render, screen } from "@testing-library/react";
import { Plus } from "@phosphor-icons/react";
import { Plus, type Icon } from "@phosphor-icons/react";
import { Button, RefreshButton, LinkButton, buttonVariants } from "./button";

describe("Button", () => {
Expand Down Expand Up @@ -150,6 +150,35 @@ describe("Button", () => {
const svg = button.querySelector("svg");
expect(svg).toBeTruthy();
});

// React Flight (RSC) can deliver an element prop as a lazy reference whose
// resolved value is an element, not a component (#891).
it.skipIf(React.version.startsWith("18."))(
"accepts an element delivered as an RSC lazy reference",
() => {
const lazyElement = {
$$typeof: Symbol.for("react.lazy"),
_payload: <Plus data-testid="plus-icon" />,
_init: (payload: React.ReactElement) => payload,
} as unknown as React.ReactNode;

render(<Button icon={lazyElement}>Add</Button>);
const button = screen.getByRole("button", { name: "Add" });
expect(button.querySelector("svg")).toBeTruthy();
},
);

it("accepts a React.lazy icon component", async () => {
const LazyPlus = React.lazy(() => Promise.resolve({ default: Plus }));

render(
<React.Suspense fallback={null}>
<Button icon={LazyPlus as unknown as Icon}>Add</Button>
</React.Suspense>,
);
const button = await screen.findByRole("button", { name: "Add" });
expect(button.querySelector("svg")).toBeTruthy();
});
});

it("title prop wraps in Tooltip and removes native title attribute", () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/kumo/src/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Tooltip } from "../tooltip/tooltip";
import { cn } from "../../utils/cn";
import { resolveVariant } from "../../utils/resolve-variant";
import { useLinkComponent } from "../../utils/link-provider";
import { unwrapLazyElement } from "../../utils/unwrap-lazy-element";

/** Button variant definitions mapping shape, size, and variant names to their Tailwind classes. */
export const KUMO_BUTTON_VARIANTS = {
Expand Down Expand Up @@ -202,7 +203,8 @@ const getTextClasses = (
};

// Normalize icon prop to support both React elements and component types
const renderIconNode = (IconComponent?: Icon | React.ReactNode) => {
const renderIconNode = (iconProp?: Icon | React.ReactNode) => {
const IconComponent = unwrapLazyElement(iconProp);
if (!IconComponent) return null;
if (React.isValidElement(IconComponent)) return IconComponent;
const Comp = IconComponent as React.ComponentType<Record<string, unknown>>;
Expand Down
21 changes: 21 additions & 0 deletions packages/kumo/src/components/dropdown/dropdown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { version } from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vite-plus/test";
import { CopyIcon, GearIcon } from "@phosphor-icons/react";
Expand Down Expand Up @@ -108,6 +109,26 @@ describe("DropdownMenu", () => {
);
expect(screen.getByTestId("custom-icon").className).toContain("h-4 w-4");
});

// React Flight (RSC) can deliver an element prop as a lazy reference whose
// resolved value is an element, not a component (#891).
it.skipIf(version.startsWith("18."))(
"accepts an icon element delivered as an RSC lazy reference",
() => {
const lazyElement = {
$$typeof: Symbol.for("react.lazy"),
_payload: <span data-testid="custom-icon" />,
_init: (payload: React.ReactElement) => payload,
} as unknown as React.ReactNode;

renderMenu(
<DropdownMenu.Item icon={lazyElement}>Item</DropdownMenu.Item>,
);
expect(screen.getByTestId("custom-icon").className).toContain(
"h-4 w-4",
);
},
);
});

describe("icon spacing", () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/kumo/src/components/dropdown/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as React from "react";
import { cn } from "../../utils/cn";
import { resolveVariant } from "../../utils/resolve-variant";
import { useLinkComponent } from "../../utils/link-provider";
import { unwrapLazyElement } from "../../utils/unwrap-lazy-element";
import {
usePortalContainer,
type PortalContainer,
Expand Down Expand Up @@ -64,7 +65,8 @@ export function dropdownVariants({
* identically. The slot also lets icon-less rows detect that a sibling has an
* icon and inset themselves to keep the text aligned.
*/
const renderIconNode = (IconComponent?: Icon | React.ReactNode) => {
const renderIconNode = (iconProp?: Icon | React.ReactNode) => {
const IconComponent = unwrapLazyElement(iconProp);
if (!IconComponent) return null;
let icon: React.ReactNode;
if (React.isValidElement<{ className?: string }>(IconComponent)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import React, {
type ReactNode,
} from "react";
import { cn } from "../../utils/cn";
import { unwrapLazyElement } from "../../utils/unwrap-lazy-element";
import { type ButtonProps, Button as ButtonExternal } from "../button/button";
import { Tooltip, type KumoTooltipSide } from "../tooltip/tooltip";
import type { KumoInputSize } from "../input/input";
Expand Down Expand Up @@ -71,7 +72,7 @@ export const Button = forwardRef<
disabled,
tooltip,
tooltipSide = "bottom",
icon,
icon: iconProp,
...props
}: PropsWithChildren<InputGroupButtonProps>,
ref: React.ForwardedRef<HTMLButtonElement>,
Expand Down Expand Up @@ -117,6 +118,7 @@ export const Button = forwardRef<
? INPUT_GROUP_SIZE[context.size ?? "base"].iconSize
: undefined;

const icon = unwrapLazyElement(iconProp);
const sizedIcon =
icon &&
contextIconSize &&
Expand Down
25 changes: 25 additions & 0 deletions packages/kumo/src/components/input-group/input-group.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,31 @@ describe("InputGroup", () => {
expect(icon.getAttribute("data-size")).toBe("42");
});

// React Flight (RSC) can deliver an element prop as a lazy reference whose
// resolved value is an element, not a component (#891).
it.skipIf(React.version.startsWith("18."))(
"passes an element delivered as an RSC lazy reference through unchanged",
() => {
const lazyElement = {
$$typeof: Symbol.for("react.lazy"),
_payload: <svg data-testid="element-icon" data-size={42} />,
_init: (payload: React.ReactElement) => payload,
} as unknown as React.ReactNode;

render(
<InputGroup size="base">
<InputGroup.Input aria-label="Test" />
<InputGroup.Addon align="end">
<InputGroup.Button icon={lazyElement} aria-label="Help" />
</InputGroup.Addon>
</InputGroup>,
);

const icon = screen.getByTestId("element-icon");
expect(icon.getAttribute("data-size")).toBe("42");
},
);

it("does not inject icon size outside InputGroup context", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

Expand Down
26 changes: 26 additions & 0 deletions packages/kumo/src/components/sidebar/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { version } from "react";
import { renderToString } from "react-dom/server";
import { hydrateRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach } from "vite-plus/test";
Expand Down Expand Up @@ -1047,6 +1048,31 @@ describe("Sidebar.MenuButton", () => {
expect(link!.getAttribute("data-active")).toBe("true");
expect(link!.getAttribute("data-kumo-part")).toBe("menu-button-link");
});

// React Flight (RSC) can deliver an element prop as a lazy reference whose
// resolved value is an element, not a component (#891).
it.skipIf(version.startsWith("18."))(
"should render an icon element delivered as an RSC lazy reference",
() => {
const lazyElement = {
$$typeof: Symbol.for("react.lazy"),
_payload: <svg data-testid="lazy-icon" />,
_init: (payload: React.ReactElement) => payload,
} as unknown as React.ReactNode;

render(
<TestSidebar defaultOpen>
<SidebarContent>
<SidebarMenu>
<SidebarMenuButton icon={lazyElement}>Home</SidebarMenuButton>
</SidebarMenu>
</SidebarContent>
</TestSidebar>,
);
const button = screen.getByRole("button", { name: "Home" });
expect(within(button).getByTestId("lazy-icon")).toBeTruthy();
},
);
});

describe("Sidebar.MenuSub", () => {
Expand Down
8 changes: 5 additions & 3 deletions packages/kumo/src/components/sidebar/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ScrollArea as ScrollAreaBase } from "@base-ui/react/scroll-area";
import { CaretRightIcon, XIcon } from "@phosphor-icons/react";
import { cn } from "../../utils/cn";
import { useLinkComponent } from "../../utils/link-provider";
import { unwrapLazyElement } from "../../utils/unwrap-lazy-element";
import { SkeletonLine } from "../loader/skeleton-line";
import { Tooltip, TooltipProvider } from "../tooltip";
import { Button } from "../button";
Expand Down Expand Up @@ -1500,9 +1501,10 @@ const SidebarMenuButton = forwardRef<HTMLButtonElement, SidebarMenuButtonProps>(

// Render icon — supports both component types and React elements
const iconNode = (() => {
if (!IconProp) return null;
if (React.isValidElement(IconProp)) return IconProp;
const Comp = IconProp as React.ComponentType<{ className?: string }>;
const icon = unwrapLazyElement(IconProp);
if (!icon) return null;
if (React.isValidElement(icon)) return icon;
const Comp = icon as React.ComponentType<{ className?: string }>;
return (
<Comp
className={cn(
Expand Down
65 changes: 65 additions & 0 deletions packages/kumo/src/utils/unwrap-lazy-element.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React from "react";
import { describe, expect, it } from "vite-plus/test";
import { Plus } from "@phosphor-icons/react";
import { unwrapLazyElement } from "./unwrap-lazy-element";

// Server Components, and with them Flight lazy elements, require React 19.
const isReact18 = React.version.startsWith("18.");

function flightLazy(payload: unknown) {
return {
$$typeof: Symbol.for("react.lazy"),
_payload: payload,
_init: (value: unknown) => value,
};
}

async function unwrapAfterLoad(value: unknown): Promise<unknown> {
try {
return unwrapLazyElement(value);
} catch (pending) {
await pending;
return unwrapLazyElement(value);
}
}

describe("unwrapLazyElement", () => {
it.skipIf(isReact18)(
"unwraps a lazy reference that resolves to an element",
() => {
const result = unwrapLazyElement<unknown>(flightLazy(<Plus />));
expect(React.isValidElement(result)).toBe(true);
expect((result as React.ReactElement).type).toBe(Plus);
},
);

it.skipIf(isReact18)("unwraps nested lazy references", () => {
const result = unwrapLazyElement<unknown>(flightLazy(flightLazy(<Plus />)));
expect((result as React.ReactElement).type).toBe(Plus);
});

it.each([
["forwardRef", Plus],
["function", () => <svg />],
["memo", React.memo(() => <svg />)],
])("returns a React.lazy %s component unchanged", async (_, component) => {
const LazyIcon = React.lazy(() =>
Promise.resolve({ default: component as React.ComponentType }),
);
expect(await unwrapAfterLoad(LazyIcon)).toBe(LazyIcon);
});

it("returns non-lazy values unchanged", () => {
const element = <Plus />;
expect(unwrapLazyElement(element)).toBe(element);
expect(unwrapLazyElement(Plus)).toBe(Plus);
expect(unwrapLazyElement("text")).toBe("text");
expect(unwrapLazyElement(null)).toBe(null);
expect(unwrapLazyElement(undefined)).toBe(undefined);
});

it("keeps the wrapper when it does not resolve to an element", () => {
const lazy = flightLazy("text");
expect(unwrapLazyElement(lazy)).toBe(lazy);
});
});
Loading
Loading