Skip to content

fix: unwrap RSC lazy elements passed as icon props - #901

Open
mattrothenberg wants to merge 3 commits into
mainfrom
fix/rsc-lazy-icon
Open

mattrothenberg wants to merge 3 commits into
mainfrom
fix/rsc-lazy-icon

Conversation

@mattrothenberg

@mattrothenberg mattrothenberg commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #891.

Problem

When an element is passed as icon from a React Server Component (e.g. <Button icon={<Star />} /> in a Next.js App Router page), React Flight can deliver it to the client as a lazy reference ($$typeof: Symbol.for("react.lazy")) instead of an element. This is react#32392.

Kumo's icon helpers did:

if (React.isValidElement(icon)) return icon;
return <Icon />;

The lazy reference fails isValidElement, so it was rendered as a component. React resolves it, gets an element instead of a component, and throws error #306.

Fix

Adds an internal unwrapLazyElement helper (not exported from the package) that unwraps a lazy reference to its element via React.Children.toArray, and calls it at the top of every icon helper. The rest of each helper is unchanged, so DropdownMenu still merges its sizing classes into element icons.

This mirrors Base UI's unwrapLazyRenderProp in @base-ui/react/internals/useRenderElement and Ark UI's asChild fix, which work around the same React issue (closed as not planned). It uses only public React APIs apart from the Symbol.for("react.lazy") check. Values that are not lazy references, or that don't unwrap to an element, are returned unchanged.

Unlike Base UI's render prop, icon also accepts components, so toArray can throw: on React 18, which does not resolve lazy values (and cannot receive Flight elements, since Server Components require React 19), and for a React.lazy component that resolves to a forwardRef or memo object such as a Phosphor icon. In those cases the helper rethrows a pending thenable so React suspends, and otherwise returns the original value, which renders as a component as before. A genuine error resurfaces when React renders that value.

Applied to:

  • Button / LinkButton
  • Badge
  • DropdownMenu items (Item, LinkItem, SubTrigger, etc.)
  • Sidebar.MenuButton
  • InputGroup.Button

Tests

Each component gets a test that passes a Flight-style lazy reference as icon; all 5 fail with React error #306 without the fix. Also adds unit tests for unwrapLazyElement.

  • Reviews
  • bonk has reviewed the change
  • automated review not possible because: requesting human review first; bonk can be run on the PR afterwards
  • Tests
  • Tests included/updated
  • Automated tests not possible - manual testing has been completed as follows:
  • Additional testing not necessary because:

Comment thread packages/kumo/src/utils/unwrap-lazy-element.ts Outdated
@ask-bonk

ask-bonk Bot commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

I'm Bonk, and I've done a quick review of your PR.

1 finding posted inline.

Review notes
  • Confirming pull request #901 and inspecting its existing review state, the component-package conventions, and the full changed implementation and tests.
  • PR #901 is confirmed at the supplied head SHA with no prior reviews. The new helper only unwraps values marked as React lazy references; next I am tracing every changed icon-rendering path and comparing the added coverage against the patch.
  • The patch modifies the shared icon normalization paths for Button/LinkButton, Badge, every dropdown row that accepts an icon, InputGroup.Button, and Sidebar.MenuButton. I found the dropdown paths consistently route through the updated helper; I am now checking for analogous unmodified paths and executing the affected test suites.
  • The analogous Toolbar component delegates its icons to the fixed Button/LinkButton implementation, while the other icon props render nodes directly and do not perform the element-versus-component dispatch that caused this failure. The targeted test command could not start because pnpm is unavailable in the runner; I am validating React-version compatibility from the installed dependency sources instead.
  • React 18 remains a declared peer dependency. Its React.Children.toArray implementation does not handle react.lazy values, whereas React 19 does; the new helper therefore turns a previously supported lazy icon component into an “Objects are not valid as a React child” runtime error under React 18. I verified the PR head SHA again before preparing one inline compatibility finding.
  • Posted one inline compatibility finding on packages/kumo/src/utils/unwrap-lazy-element.ts. No other actionable defects were identified in the changed implementation or its added coverage.

github run

@clanky-the-clanker

clanky-the-clanker Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

✅ Approved — No issues found. The Children.toArray-based unwrapLazyElement gives the right result for Flight lazy elements, pending references, React.lazy icon components and React 18.

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.


✅ All checks passed

🔒 Security: ✅ LGTM

No security concerns.


📖 AGENTS.md: ✅ LGTM

This pull request follows the repository conventions: the packages/kumo change has a changeset, and the description has the Reviews/Tests checklist with one option checked in each section.


📋 Code Quality: ✅ LGTM

I traced unwrapLazyElement (packages/kumo/src/utils/unwrap-lazy-element.ts:43-53) through each kind of value it can receive:

  • Flight lazy element on React 19: toArray resolves it to its element, so the icon helpers reach their existing isValidElement branch. This fixes React error feat(tokens): updated greyscale tokens #306.
  • Pending reference: the thrown thenable is rethrown, so React suspends at the nearest Suspense boundary. The component that owns the icon (for example Button) suspends, not only the icon. In practice this changes nothing.
  • React.lazy icon component:
    • If it resolves to a function, toArray returns nothing and the original value is returned.
    • If it resolves to a forwardRef or memo object, such as a Phosphor icon, toArray throws an error that is not a thenable, and the original value is returned.
    • In both cases the existing component branch renders the icon as before.
  • Rejected lazy: the original value is returned, and React raises the real error when it renders that value.
  • React 18: toArray throws on any lazy value, so the value passes through unchanged, as it did before this pull request. Flight lazy elements cannot reach React 18, because Server Components require React 19.

The React 18 concern raised earlier in the discussion is handled: the helper no longer throws for React.lazy icons on React 18. Each of the five call sites adds a single helper call and then uses the unwrapped value consistently.

One harmless detail: toArray gives the returned element a generated key. That has no effect on a single icon.


⚡ Performance: ✅ LGTM

For values that are not lazy references, the helper does one $$typeof comparison and returns. Lazy values cost one Children.toArray call on a single element. There is no measurable overhead.


📝 Documentation: ✅ LGTM

The changeset and the ai/USAGE.md note on element icons are accurate. The helper's doc comment describes the current toArray-based behaviour.


Reviewed commit: 10a1773a

@pkg-pr-new

pkg-pr-new Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
npm i https://pkg.pr.new/@cloudflare/kumo@901

commit: 10a1773

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Visual contracts

Linux Chromium references exercised by the PR's visual-contracts job.

1 curated contract

truncating text trigger

truncating text trigger

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://fix-rsc-lazy-icon-kumo-docs.design-engineering.workers.dev (commit 10a1773)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://92cb943f-kumo-docs.design-engineering.workers.dev 10a1773 2026-10-28T17:29:22.192Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://2c0fb7fd-kumo-docs.design-engineering.workers.dev a12d66e 2026-10-28T16:26:52.179Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://6ab22c47-kumo-docs.design-engineering.workers.dev cb08ca0 2026-10-28T15:49:04.907Z Visit the dashboard ↗

React 18's Children.toArray does not resolve react.lazy values, so it threw
for Flight lazy elements and for React.lazy icon components. Resolve the lazy
reference directly instead, and document element icons in USAGE.md.
@mattrothenberg

Copy link
Copy Markdown
Collaborator Author

Addressed review feedback in a12d66e:

  • React 18 compatibility (bonk; failing test-react-compatibility (18.3.1) job): unwrapLazyElement no longer uses React.Children.toArray, which does not resolve react.lazy values in React 18. It resolves the reference directly, so both Flight lazy elements and React.lazy icon components work on React 18 and 19. Added tests for a React.lazy icon on Button and for nested references.
  • Docs (clanky): ai/USAGE.md now notes that icon accepts a rendered element, including one passed from a Server Component.
  • test job: all 1530 tests passed; the job was cancelled for exceeding its 5-minute limit during cleanup. The new push reruns it.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📐 Bundle Size

Consumer fixtures bundled against packages/kumo/dist (peers external, minified ESM).

Fixture Raw Gzip Brotli
Button (root) 167.1 KB 51.1 KB 44.3 KB
Button (components/button) 167.0 KB 51.0 KB 44.3 KB
Button + Dialog + Select (root) 294.0 KB 87.7 KB 73.8 KB
Button + Dialog + Select (component subpaths) 294.0 KB 87.8 KB 73.9 KB
Chart (root) 238.5 KB 71.9 KB 61.9 KB
Chart (components/chart) 238.5 KB 71.9 KB 61.9 KB
Badge (components/badge) 36.9 KB 12.9 KB 11.2 KB
Flow (components/flow) 200.7 KB 59.2 KB 51.8 KB
Button (primitives/button) 12.4 KB 4.5 KB 4.0 KB
Primitives barrel 718.4 KB 198.5 KB 154.5 KB
Code highlighting (code) 2.07 MB 469.4 KB 350.7 KB

npm tarball: 566 files, 1.56 MB packed, 7.30 MB unpacked.

⚠️ 25 flagged files in tarball (tests / raw scripts)
  • dist/blocks-source/delete-resource/delete-resource.test.tsx
  • dist/blocks-source/resource-list/resource-list.test.tsx
  • scripts/component-registry/cache.ts
  • scripts/component-registry/discovery.ts
  • scripts/component-registry/example-cleanup.ts
  • scripts/component-registry/index.test.ts
  • scripts/component-registry/index.ts
  • scripts/component-registry/markdown-generator.ts
  • scripts/component-registry/metadata.ts
  • scripts/component-registry/props-filter.ts
  • scripts/component-registry/schema-generator.ts
  • scripts/component-registry/sub-components.ts
  • scripts/component-registry/types.ts
  • scripts/component-registry/utils.ts
  • scripts/component-registry/variant-parser.ts
  • scripts/css-build.ts
  • scripts/format-generated.ts
  • scripts/generate-globe-land-mask.mjs
  • scripts/generate-primitives.ts
  • scripts/theme-generator/config.ts
  • scripts/theme-generator/generate-css.test.ts
  • scripts/theme-generator/generate-css.ts
  • scripts/theme-generator/index.ts
  • scripts/theme-generator/migrate.ts
  • scripts/theme-generator/types.ts

Report-only: no size thresholds are enforced yet.

Commit: 10a1773a

Drop the _init/_payload access in favour of the public Children.toArray, as
Base UI and Ark UI do. Fall back to the original value when toArray throws
for anything other than a pending thenable: React 18, which cannot carry
Flight lazy elements, and React.lazy components resolving to forwardRef or
memo objects such as Phosphor icons. Skip the Flight element tests on
React 18.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Button, Badge, DropdownMenu crash with React #306 when icon is an element passed from a Server Component

1 participant