From ed98bad7aeeb6e02d20338b323d0e1ae1dd37d1f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 11 Apr 2024 11:26:57 +0200 Subject: [PATCH] Use native `useId` and `useSyncExternalStore` hooks (#3092) * use native `useSyncExternalStore` hook from React 18 * use native `useId` hook from React 18 * update changelog --- packages/@headlessui-react/CHANGELOG.md | 1 + .../@headlessui-react/src/hooks/use-id.ts | 26 +-- .../@headlessui-react/src/hooks/use-store.ts | 2 +- .../src/use-sync-external-store-shim/index.ts | 38 ----- .../useSyncExternalStoreShimClient.ts | 154 ------------------ .../useSyncExternalStoreShimServer.ts | 22 --- 6 files changed, 4 insertions(+), 239 deletions(-) delete mode 100644 packages/@headlessui-react/src/use-sync-external-store-shim/index.ts delete mode 100644 packages/@headlessui-react/src/use-sync-external-store-shim/useSyncExternalStoreShimClient.ts delete mode 100644 packages/@headlessui-react/src/use-sync-external-store-shim/useSyncExternalStoreShimServer.ts diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 3d8cebe20..6c9ecf293 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Attempt form submission when pressing `Enter` on the `` component ([#2972](https://github.com/tailwindlabs/headlessui/pull/2972)) - Make the `Combobox` component `nullable` by default ([#3064](https://github.com/tailwindlabs/headlessui/pull/3064)) - Deprecate the `entered` prop on the `Transition` component ([#3089](https://github.com/tailwindlabs/headlessui/pull/3089)) +- Use native `useId` and `useSyncExternalStore` hooks ([#3092](https://github.com/tailwindlabs/headlessui/pull/3092)) ### Added diff --git a/packages/@headlessui-react/src/hooks/use-id.ts b/packages/@headlessui-react/src/hooks/use-id.ts index 8b18bf7b1..1003f0bfb 100644 --- a/packages/@headlessui-react/src/hooks/use-id.ts +++ b/packages/@headlessui-react/src/hooks/use-id.ts @@ -1,24 +1,2 @@ -import React from 'react' -import { env } from '../utils/env' -import { useIsoMorphicEffect } from './use-iso-morphic-effect' -import { useServerHandoffComplete } from './use-server-handoff-complete' - -// We used a "simple" approach first which worked for SSR and rehydration on the client. However we -// didn't take care of the Suspense case. To fix this we used the approach the @reach-ui/auto-id -// uses. -// -// Credits: https://github.com/reach/reach-ui/blob/develop/packages/auto-id/src/index.tsx - -export let useId = - // Prefer React's `useId` if it's available. - React.useId ?? - function useId() { - let ready = useServerHandoffComplete() - let [id, setId] = React.useState(ready ? () => env.nextId() : null) - - useIsoMorphicEffect(() => { - if (id === null) setId(env.nextId()) - }, [id]) - - return id != null ? '' + id : undefined - } +// Re-exporting the useId hook such that we can easily mock this hook in tests. +export { useId } from 'react' diff --git a/packages/@headlessui-react/src/hooks/use-store.ts b/packages/@headlessui-react/src/hooks/use-store.ts index 25f14ef7b..f625d9194 100644 --- a/packages/@headlessui-react/src/hooks/use-store.ts +++ b/packages/@headlessui-react/src/hooks/use-store.ts @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from '../use-sync-external-store-shim/index' +import { useSyncExternalStore } from 'react' import type { Store } from '../utils/store' export function useStore(store: Store) { diff --git a/packages/@headlessui-react/src/use-sync-external-store-shim/index.ts b/packages/@headlessui-react/src/use-sync-external-store-shim/index.ts deleted file mode 100644 index 28a6609e7..000000000 --- a/packages/@headlessui-react/src/use-sync-external-store-shim/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -// This was taken from the ESM / CJS compatible version found in Remix Router: -// https://github.com/remix-run/react-router/tree/43cc1aacd8b132507618a4a1dd7de3674cd7bcf4/packages/react-router/lib/use-sync-external-store-shim - -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -import * as React from 'react' -import { useSyncExternalStore as client } from './useSyncExternalStoreShimClient' -import { useSyncExternalStore as server } from './useSyncExternalStoreShimServer' - -const canUseDOM: boolean = !!( - typeof window !== 'undefined' && - typeof window.document !== 'undefined' && - typeof window.document.createElement !== 'undefined' -) - -const isServerEnvironment = !canUseDOM -const shim = isServerEnvironment ? server : client - -type UseSyncExternalStoreFn = ( - subscribe: (fn: () => void) => () => void, - getSnapshot: () => T, - // Note: The shim does not use getServerSnapshot, because pre-18 versions of - // React do not expose a way to check if we're hydrating. So users of the shim - // will need to track that themselves and return the correct value - // from `getSnapshot`. - getServerSnapshot?: () => T -) => T - -// @ts-ignore -export const useSyncExternalStore: UseSyncExternalStoreFn = - 'useSyncExternalStore' in React ? ((r) => r.useSyncExternalStore)(React) : shim diff --git a/packages/@headlessui-react/src/use-sync-external-store-shim/useSyncExternalStoreShimClient.ts b/packages/@headlessui-react/src/use-sync-external-store-shim/useSyncExternalStoreShimClient.ts deleted file mode 100644 index 2ebb84934..000000000 --- a/packages/@headlessui-react/src/use-sync-external-store-shim/useSyncExternalStoreShimClient.ts +++ /dev/null @@ -1,154 +0,0 @@ -// @ts-nocheck - -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import * as React from 'react' - -// Make typescript happy -declare var __DEV__: boolean - -/** - * inlined Object.is polyfill to avoid requiring consumers ship their own - * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is - */ -function isPolyfill(x: any, y: any) { - return ( - (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare - ) -} - -const is: (x: any, y: any) => boolean = typeof Object.is === 'function' ? Object.is : isPolyfill - -// Intentionally not using named imports because Rollup uses dynamic -// dispatch for CommonJS interop named imports. -const { useState, useEffect, useLayoutEffect, useDebugValue } = React - -let didWarnOld18Alpha = false -let didWarnUncachedGetSnapshot = false - -// Disclaimer: This shim breaks many of the rules of React, and only works -// because of a very particular set of implementation details and assumptions -// -- change any one of them and it will break. The most important assumption -// is that updates are always synchronous, because concurrent rendering is -// only available in versions of React that also have a built-in -// useSyncExternalStore API. And we only use this shim when the built-in API -// does not exist. -// -// Do not assume that the clever hacks used by this hook also work in general. -// The point of this shim is to replace the need for hacks by other libraries. -export function useSyncExternalStore( - subscribe: (fn: () => void) => () => void, - getSnapshot: () => T, - // Note: The shim does not use getServerSnapshot, because pre-18 versions of - // React do not expose a way to check if we're hydrating. So users of the shim - // will need to track that themselves and return the correct value - // from `getSnapshot`. - getServerSnapshot?: () => T -): T { - if (__DEV__) { - if (!didWarnOld18Alpha) { - if ('startTransition' in React) { - didWarnOld18Alpha = true - console.error( - 'You are using an outdated, pre-release alpha of React 18 that ' + - 'does not support useSyncExternalStore. The ' + - 'use-sync-external-store shim will not work correctly. Upgrade ' + - 'to a newer pre-release.' - ) - } - } - } - - // Read the current snapshot from the store on every render. Again, this - // breaks the rules of React, and only works here because of specific - // implementation details, most importantly that updates are - // always synchronous. - const value = getSnapshot() - if (__DEV__) { - if (!didWarnUncachedGetSnapshot) { - const cachedValue = getSnapshot() - if (!is(value, cachedValue)) { - console.error('The result of getSnapshot should be cached to avoid an infinite loop') - didWarnUncachedGetSnapshot = true - } - } - } - - // Because updates are synchronous, we don't queue them. Instead we force a - // re-render whenever the subscribed state changes by updating an some - // arbitrary useState hook. Then, during render, we call getSnapshot to read - // the current value. - // - // Because we don't actually use the state returned by the useState hook, we - // can save a bit of memory by storing other stuff in that slot. - // - // To implement the early bailout, we need to track some things on a mutable - // object. Usually, we would put that in a useRef hook, but we can stash it in - // our useState hook instead. - // - // To force a re-render, we call forceUpdate({inst}). That works because the - // new object always fails an equality check. - const [{ inst }, forceUpdate] = useState({ inst: { value, getSnapshot } }) - - // Track the latest getSnapshot function with a ref. This needs to be updated - // in the layout phase so we can access it during the tearing check that - // happens on subscribe. - useLayoutEffect(() => { - inst.value = value - inst.getSnapshot = getSnapshot - - // Whenever getSnapshot or subscribe changes, we need to check in the - // commit phase if there was an interleaved mutation. In concurrent mode - // this can happen all the time, but even in synchronous mode, an earlier - // effect may have mutated the store. - if (checkIfSnapshotChanged(inst)) { - // Force a re-render. - forceUpdate({ inst }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [subscribe, value, getSnapshot]) - - useEffect(() => { - // Check for changes right before subscribing. Subsequent changes will be - // detected in the subscription handler. - if (checkIfSnapshotChanged(inst)) { - // Force a re-render. - forceUpdate({ inst }) - } - const handleStoreChange = () => { - // TODO: Because there is no cross-renderer API for batching updates, it's - // up to the consumer of this library to wrap their subscription event - // with unstable_batchedUpdates. Should we try to detect when this isn't - // the case and print a warning in development? - - // The store changed. Check if the snapshot changed since the last time we - // read from the store. - if (checkIfSnapshotChanged(inst)) { - // Force a re-render. - forceUpdate({ inst }) - } - } - // Subscribe to the store and return a clean-up function. - return subscribe(handleStoreChange) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [subscribe]) - - useDebugValue(value) - return value -} - -function checkIfSnapshotChanged(inst: any) { - const latestGetSnapshot = inst.getSnapshot - const prevValue = inst.value - try { - const nextValue = latestGetSnapshot() - return !is(prevValue, nextValue) - } catch (error) { - return true - } -} diff --git a/packages/@headlessui-react/src/use-sync-external-store-shim/useSyncExternalStoreShimServer.ts b/packages/@headlessui-react/src/use-sync-external-store-shim/useSyncExternalStoreShimServer.ts deleted file mode 100644 index 2d550daec..000000000 --- a/packages/@headlessui-react/src/use-sync-external-store-shim/useSyncExternalStoreShimServer.ts +++ /dev/null @@ -1,22 +0,0 @@ -// @ts-nocheck - -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export function useSyncExternalStore( - subscribe: (fn: () => void) => () => void, - getSnapshot: () => T, - getServerSnapshot?: () => T -): T { - // Note: The shim does not use getServerSnapshot, because pre-18 versions of - // React do not expose a way to check if we're hydrating. So users of the shim - // will need to track that themselves and return the correct value - // from `getSnapshot`. - return getSnapshot() -}