Repository: tailwindlabs/headlessui
Pull request #3253 (2024-05-29 12:56:17)

Fix missing act warnings issued from `focus` and `blur` calls
Without the `act` you may see unrelated test failures in React 19.


Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index b8eab99..25ae52a 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Merge incoming `style` prop on `ComboboxOptions`, `ListboxOptions`, `MenuItems`, and `PopoverPanel` components ([#3250](https://github.com/tailwindlabs/headlessui/pull/3250))
 - Prevent focus on `<Checkbox />` when it is `disabled` ([#3251](https://github.com/tailwindlabs/headlessui/pull/3251))
 - Fix visual jitter in `Combobox` component when using native scrollbar ([#3190](https://github.com/tailwindlabs/headlessui/pull/3190))
+- Use `useId` instead of React internals (for React 19 compatibility) ([#3254](https://github.com/tailwindlabs/headlessui/pull/3254))
 
 ## [2.0.4] - 2024-05-25
 
diff --git a/packages/@headlessui-react/src/utils/stable-collection.tsx b/packages/@headlessui-react/src/utils/stable-collection.tsx
index e9feacf..e2b7c10 100644
--- a/packages/@headlessui-react/src/utils/stable-collection.tsx
+++ b/packages/@headlessui-react/src/utils/stable-collection.tsx
@@ -18,6 +18,9 @@ function createCollection() {
       }
 
       let renders = list.get(key) ?? 0
+      // FIXME: This is a side-effect during render. `release` is only called in
+      // an effect cleanup so we may never release if we had to render multiple
+      // times before commit e.g. when a sibling suspends.
       list.set(key, renders + 1)
 
       let index = Array.from(list.keys()).indexOf(key)
@@ -49,33 +52,8 @@ export function useStableCollectionIndex(group: string) {
   let collection = React.useContext(StableCollectionContext)
   if (!collection) throw new Error('You must wrap your component in a <StableCollection>')
 
-  let key = useStableCollectionKey()
+  let key = React.useId()
   let [idx, cleanupIdx] = collection.current.get(group, key)
   React.useEffect(() => cleanupIdx, [])
   return idx
 }
-
-/**
- * Return a stable key based on the position of this node.
- *
- * @returns {symbol | string}
- */
-function useStableCollectionKey() {
-  let owner =
-    // @ts-ignore
-    React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED?.ReactCurrentOwner?.current ?? null
-
-  // ssr: dev/prod
-  // client: prod
-  if (!owner) return Symbol()
-
-  // client: dev
-  let indexes = []
-  let fiber = owner
-  while (fiber) {
-    indexes.push(fiber.index)
-    fiber = fiber.return
-  }
-
-  return '$.' + indexes.join('.')
-}
diff --git a/packages/@headlessui-tailwindcss/CHANGELOG.md b/packages/@headlessui-tailwindcss/CHANGELOG.md
index a1ceca3..e72d035 100644
--- a/packages/@headlessui-tailwindcss/CHANGELOG.md
+++ b/packages/@headlessui-tailwindcss/CHANGELOG.md
@@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+- Nothing yet!
+
+## [0.2.1] - 2024-05-29
+
 ### Fixed
 
-- Fix ESM types ([#2880](https://github.com/tailwindlabs/headlessui/pull/2880))
+- Improve CJS types ([#2880](https://github.com/tailwindlabs/headlessui/pull/2880))
+- Improve ESM types ([#3051](https://github.com/tailwindlabs/headlessui/pull/3051))
 
 ## [0.2.0] - 2023-07-27
 
@@ -42,7 +47,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 - Add `@headlessui/tailwindcss` plugin ([#1487](https://github.com/tailwindlabs/headlessui/pull/1487))
 
-[unreleased]: https://github.com/tailwindlabs/headlessui/compare/@headlessui/tailwindcss@v0.2.0...HEAD
+[unreleased]: https://github.com/tailwindlabs/headlessui/compare/@headlessui/tailwindcss@v0.2.1...HEAD
+[0.2.1]: https://github.com/tailwindlabs/headlessui/compare/@headlessui/tailwindcss@v0.2.0...@headlessui/tailwindcss@v0.2.1
 [0.2.0]: https://github.com/tailwindlabs/headlessui/compare/@headlessui/tailwindcss@v0.1.3...@headlessui/tailwindcss@v0.2.0
 [0.1.3]: https://github.com/tailwindlabs/headlessui/compare/@headlessui/tailwindcss@v0.1.2...@headlessui/tailwindcss@v0.1.3
 [0.1.2]: https://github.com/tailwindlabs/headlessui/compare/@headlessui/tailwindcss@v0.1.1...@headlessui/tailwindcss@v0.1.2
diff --git a/packages/@headlessui-tailwindcss/package.json b/packages/@headlessui-tailwindcss/package.json
index c13cc84..015e362 100644
--- a/packages/@headlessui-tailwindcss/package.json
+++ b/packages/@headlessui-tailwindcss/package.json
@@ -1,10 +1,11 @@
 {
   "name": "@headlessui/tailwindcss",
-  "version": "0.2.0",
+  "version": "0.2.1",
   "description": "A complementary Tailwind CSS plugin",
   "main": "dist/index.cjs",
   "types": "dist/index.d.ts",
   "module": "dist/headlessui.esm.js",
+  "type": "module",
   "license": "MIT",
   "files": [
     "README.md",
@@ -32,7 +33,7 @@
   },
   "scripts": {
     "prepublishOnly": "npm run build",
-    "build": "../../scripts/build.sh --external:tailwindcss && node ./scripts/fix-types.js",
+    "build": "../../scripts/build.sh --external:tailwindcss && node ./scripts/fix-types.cjs",
     "watch": "../../scripts/watch.sh --external:tailwindcss",
     "test": "../../scripts/test.sh",
     "lint": "../../scripts/lint.sh",
diff --git a/packages/@headlessui-tailwindcss/scripts/fix-types.js b/packages/@headlessui-tailwindcss/scripts/fix-types.cjs
similarity index 100%
rename from packages/@headlessui-tailwindcss/scripts/fix-types.js
rename to packages/@headlessui-tailwindcss/scripts/fix-types.cjs
diff --git a/packages/@headlessui-tailwindcss/src/index.ts b/packages/@headlessui-tailwindcss/src/index.ts
index 91d3dde..6a75926 100644
--- a/packages/@headlessui-tailwindcss/src/index.ts
+++ b/packages/@headlessui-tailwindcss/src/index.ts
@@ -1,4 +1,4 @@
-import plugin from 'tailwindcss/plugin'
+import plugin from 'tailwindcss/plugin.js'
 
 interface Options {
   /**


Test patch:
diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts
index 4b94e3e..6e2c608 100644
--- a/packages/@headlessui-react/src/test-utils/interactions.ts
+++ b/packages/@headlessui-react/src/test-utils/interactions.ts
@@ -254,7 +254,10 @@ export async function rawClick(
         let next: HTMLElement | null = element as HTMLElement | null
         while (next !== null) {
           if (next.matches(focusableSelector)) {
-            next.focus()
+            act(() => {
+              // act scopes are called immediately. `next` should keep its type refinements.
+              next!.focus()
+            })
             break
           }
           next = next.parentElement
@@ -288,37 +291,40 @@ export async function rawClick(
 }
 
 export async function focus(element: Document | Element | Window | Node | null) {
-  try {
-    if (element === null) return expect(element).not.toBe(null)
+  await act(async () => {
+    try {
+      if (element === null) return expect(element).not.toBe(null)
+      if (element instanceof HTMLElement) {
+        element.focus()
+      } else {
+        fireEvent.focus(element)
+      }
 
-    if (element instanceof HTMLElement) {
-      element.focus()
-    } else {
-      fireEvent.focus(element)
+      await new Promise(nextFrame)
+    } catch (err) {
+      if (err instanceof Error) Error.captureStackTrace(err, focus)
+      throw err
     }
-
-    await new Promise(nextFrame)
-  } catch (err) {
-    if (err instanceof Error) Error.captureStackTrace(err, focus)
-    throw err
-  }
+  })
 }
 
 export async function blur(element: Document | Element | Window | Node | null) {
-  try {
-    if (element === null) return expect(element).not.toBe(null)
+  await act(async () => {
+    try {
+      if (element === null) return expect(element).not.toBe(null)
+
+      if (element instanceof HTMLElement) {
+        element.blur()
+      } else {
+        fireEvent.blur(element)
+      }
 
-    if (element instanceof HTMLElement) {
-      element.blur()
-    } else {
-      fireEvent.blur(element)
+      await new Promise(nextFrame)
+    } catch (err) {
+      if (err instanceof Error) Error.captureStackTrace(err, blur)
+      throw err
     }
-
-    await new Promise(nextFrame)
-  } catch (err) {
-    if (err instanceof Error) Error.captureStackTrace(err, blur)
-    throw err
-  }
+  })
 }
 
 export async function mouseEnter(element: Document | Element | Window | null) {
@@ -401,7 +407,10 @@ export async function mouseDrag(
       let next: HTMLElement | null = startingElement as HTMLElement | null
       while (next !== null) {
         if (next.matches(focusableSelector)) {
-          next.focus()
+          act(() => {
+            // act scopes are called immediately. `next` should keep its type refinements.
+            next!.focus()
+          })
           break
         }
         next = next.parentElement
@@ -455,7 +464,11 @@ function focusNext(event: Partial<KeyboardEvent>) {
     let currentIdx = focusableElements.indexOf(document.activeElement as HTMLElement)
     let next = focusableElements[(currentIdx + total + direction + offset) % total] as HTMLElement
 
-    if (next) next?.focus({ preventScroll: true })
+    if (next) {
+      act(() => {
+        next?.focus({ preventScroll: true })
+      })
+    }
 
     if (next !== document.activeElement) return innerFocusNext(offset + direction)
     return next