Repository: tailwindlabs/headlessui
Pull request #3313 (2024-06-21 17:18:02)

Fix issues spreading omitted props onto components
The existing `PropsForFeatures` type is meant to enable mutually exclusive props. 

A component may accept `static` and `unmount` but may not accept both simultaneously. We encoded this in the typescript types but it's causing more problems than it solves. For example, `Omit<ComponentProps, 'className'>` can't be spread back onto the component.

So this basically replaced the three cases with the following:
1. `{ static?: boolean }`
2. `{ unmount?: boolean }`
3. `{ static?: boolean, unmount?: boolean }`

This makes TypeScript much happier about the types and should help typeschecking performance too.

vercel[bot]: [vc]: #7rtUDPuP2yNcfbi9RoDOHKcy87Au2KZ93NdfnOQk7ZI=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJoZWFkbGVzc3VpLXJlYWN0Iiwicm9vdERpcmVjdG9yeSI6InBsYXlncm91bmRzL3JlYWN0IiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGFpbHdpbmRsYWJzL2hlYWRsZXNzdWktcmVhY3QvZWZpcjVUNUNhZWc2Y1dUM0g0eDFES1pZR0xWQSIsIm5leHRDb21taXRTdGF0dXMiOiJGQUlMRUQiLCJwcmV2aWV3VXJsIjoiIn0seyJuYW1lIjoiaGVhZGxlc3N1aS12dWUiLCJyb290RGlyZWN0b3J5IjoicGxheWdyb3VuZHMvdnVlIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImhlYWRsZXNzdWktdnVlLWdpdC1maXgtc2ltcGxpZnktcHJvcHMtdHlwZXMtdGFpbHdpbmRsYWJzLnZlcmNlbC5hcHAifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RhaWx3aW5kbGFicy9oZWFkbGVzc3VpLXZ1ZS82d0Y5enZXeW5NR1gyRlRoeUpQWTN0RGtpWVZLIiwicHJldmlld1VybCI6ImhlYWRsZXNzdWktdnVlLWdpdC1maXgtc2ltcGxpZnktcHJvcHMtdHlwZXMtdGFpbHdpbmRsYWJzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **headlessui-react** | ❌ Failed ([Inspect](https://vercel.com/tailwindlabs/headlessui-react/efir5T5Caeg6cWT3H4x1DKZYGLVA)) |  |  | Jun 21, 2024 5:18pm |
| **headlessui-vue** | ✅ Ready ([Inspect](https://vercel.com/tailwindlabs/headlessui-vue/6wF9zvWynMGX2FThyJPY3tDkiYVK)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-vue-git-fix-simplify-props-types-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-vue-git-fix-simplify-props-types-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | Jun 21, 2024 5:18pm |




Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 6145da9..6ffd78b 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
-- Nothing yet!
+### Fixed
+
+- Fix issues spreading omitted props onto components ([#3313](https://github.com/tailwindlabs/headlessui/pull/3313))
 
 ## [2.1.0] - 2024-06-21
 
diff --git a/packages/@headlessui-react/src/components/transition/transition.tsx b/packages/@headlessui-react/src/components/transition/transition.tsx
index d2d4189..a911b49 100644
--- a/packages/@headlessui-react/src/components/transition/transition.tsx
+++ b/packages/@headlessui-react/src/components/transition/transition.tsx
@@ -318,7 +318,6 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
     leaveFrom,
     leaveTo,
 
-    // @ts-expect-error
     ...theirProps
   } = props as typeof props
   let container = useRef<HTMLElement | null>(null)
@@ -444,6 +443,8 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
     className:
       classNames(
         // Incoming classes if any
+        // @ts-expect-error: className may not exist because not
+        // all components accept className (but all HTML elements do)
         theirProps.className,
 
         // Apply these classes immediately
@@ -498,7 +499,6 @@ function TransitionRootFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_C
   props: TransitionRootProps<TTag>,
   ref: Ref<HTMLElement>
 ) {
-  // @ts-expect-error
   let { show, appear = false, unmount = true, ...theirProps } = props as typeof props
   let internalTransitionRef = useRef<HTMLElement | null>(null)
   let requiresRef = shouldForwardRef(props)
@@ -610,10 +610,8 @@ function ChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_CHILD_TAG>
   return (
     <>
       {!hasTransitionContext && hasOpenClosedContext ? (
-        // @ts-expect-error This is an object
         <TransitionRoot ref={ref} {...props} />
       ) : (
-        // @ts-expect-error This is an object
         <InternalTransitionChild ref={ref} {...props} />
       )}
     </>
diff --git a/packages/@headlessui-react/src/types.ts b/packages/@headlessui-react/src/types.ts
index cbd9b9a..8bf566b 100644
--- a/packages/@headlessui-react/src/types.ts
+++ b/packages/@headlessui-react/src/types.ts
@@ -2,12 +2,6 @@ import type { JSXElementConstructor, ReactElement, ReactNode } from 'react'
 
 export type ReactTag = keyof JSX.IntrinsicElements | JSXElementConstructor<any>
 
-// A unique placeholder we can use as a default. This is nice because we can use this instead of
-// defaulting to null / never / ... and possibly collide with actual data.
-// Ideally we use a unique symbol here.
-let __ = '1D45E01E-AF44-47C4-988A-19A94EBAF55C' as const
-export type __ = typeof __
-
 export type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never
 
 export type PropsOf<TTag extends ReactTag> = TTag extends React.ElementType
@@ -55,15 +49,4 @@ export type Props<
   ClassNameOverride<TTag, TSlot> &
   Overrides
 
-type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }
-export type XOR<T, U> = T | U extends __
-  ? never
-  : T extends __
-    ? U
-    : U extends __
-      ? T
-      : T | U extends object
-        ? (Without<T, U> & U) | (Without<U, T> & T)
-        : T | U
-
 export type EnsureArray<T> = T extends any[] ? T : Expand<T>[]
diff --git a/packages/@headlessui-react/src/utils/render.ts b/packages/@headlessui-react/src/utils/render.ts
index f3eda31..4106c4b 100644
--- a/packages/@headlessui-react/src/utils/render.ts
+++ b/packages/@headlessui-react/src/utils/render.ts
@@ -11,7 +11,7 @@ import {
   type ReactElement,
   type Ref,
 } from 'react'
-import type { Expand, Props, XOR, __ } from '../types'
+import type { Expand, Props } from '../types'
 import { classNames } from './class-names'
 import { match } from './match'
 
@@ -40,17 +40,21 @@ export enum RenderStrategy {
   Hidden,
 }
 
+type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (x: infer R) => any
+  ? R
+  : never
+
 type PropsForFeature<
   TPassedInFeatures extends RenderFeatures,
   TForFeature extends RenderFeatures,
   TProps,
-> = {
-  [P in TPassedInFeatures]: P extends TForFeature ? TProps : __
-}[TPassedInFeatures]
+> = TPassedInFeatures extends TForFeature ? TProps : {}
 
-export type PropsForFeatures<T extends RenderFeatures> = XOR<
-  PropsForFeature<T, RenderFeatures.Static, { static?: boolean }>,
-  PropsForFeature<T, RenderFeatures.RenderStrategy, { unmount?: boolean }>
+export type PropsForFeatures<T extends RenderFeatures> = Expand<
+  UnionToIntersection<
+    | PropsForFeature<T, RenderFeatures.Static, { static?: boolean }>
+    | PropsForFeature<T, RenderFeatures.RenderStrategy, { unmount?: boolean }>
+  >
 >
 
 export function render<TFeature extends RenderFeatures, TTag extends ElementType, TSlot>({


Test patch:
diff --git a/packages/@headlessui-react/src/utils/render.test.tsx b/packages/@headlessui-react/src/utils/render.test.tsx
index f6844c5..c1ede83 100644
--- a/packages/@headlessui-react/src/utils/render.test.tsx
+++ b/packages/@headlessui-react/src/utils/render.test.tsx
@@ -423,16 +423,6 @@ describe('Features.Static | Features.RenderStrategy', () => {
     )
   }
 
-  // TODO: Can we "legit" test this? 🤔
-  it('should result in a typescript error', () => {
-    testRender(
-      // @ts-expect-error static & unmount together are incompatible
-      <Dummy show={false} static unmount>
-        Contents
-      </Dummy>
-    )
-  })
-
   // To avoid duplication, and to make sure that the features tested in isolation can also be
   // re-used when they are combined.
   testStaticFeature(Dummy)