diff --git a/src/__tests__/utils.test.tsx b/src/__tests__/utils.test.tsx index 2439cef..20ffb50 100644 --- a/src/__tests__/utils.test.tsx +++ b/src/__tests__/utils.test.tsx @@ -1,7 +1,41 @@ import React from 'react'; -import { useEffectAfterMount, useControlledState, callAll } from '../utils'; +import { + useEffectAfterMount, + useControlledState, + callAll, + useUniqueId, +} from '../utils'; import { render, act } from '@testing-library/react'; +describe('useUniqueId', () => { + it('should generate a unique ID value', () => { + function Comp() { + const justNull = null; + const randId = useUniqueId(justNull); + const randId2 = useUniqueId(); + return ( +
+
Wow
+
Ok
+
+ ); + } + const { getByText } = render(); + const id1 = Number(getByText('Wow').id); + const id2 = Number(getByText('Ok').id); + expect(id2).not.toEqual(id1); + }); + + it('uses a fallback ID', () => { + function Comp() { + const newId = useUniqueId('awesome'); + return
Ok
; + } + const { getByText } = render(); + expect(getByText('Ok').id).toEqual('awesome'); + }); +}); + describe('callAll', () => { it('it calls the two functions passed into it', () => { const functionOne = jest.fn(); diff --git a/src/utils.ts b/src/utils.ts index 29cba94..3e03b7a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,11 @@ -import { RefObject, useState, useRef, useEffect, useCallback } from 'react'; +import { + RefObject, + useState, + useRef, + useEffect, + useCallback, + useLayoutEffect, +} from 'react'; import warning from 'tiny-warning'; import { AssignableRef } from './types'; @@ -118,19 +125,56 @@ export function useEffectAfterMount( }, dependencies); } -// Unique ID implementation borrowed from React UI :) -// https://github.com/reach/reach-ui/blob/6e9dbcf716d5c9a3420e062e5bac1ac4671d01cb/packages/auto-id/src/index.js -let idCounter = 0; -const genId = (): number => ++idCounter; - /** - * This generates a unique ID for an instance of Collapse - * @return {String} the unique ID + * Taken from Reach + * https://github.com/reach/reach-ui/blob/d2b88c50caf52f473a7d20a4493e39e3c5e95b7b/packages/auto-id + * + * Autogenerate IDs to facilitate WAI-ARIA and server rendering. + * + * Note: The returned ID will initially be `null` and will update after a + * component mounts. Users may need to supply their own ID if they need + * consistent values for SSR. + * + * @see Docs https://reach.tech/auto-id */ -export function useUniqueId(): number { - const [id, setId] = useState(0); - useEffect(() => setId(genId()), []); - return id; +const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useEffect : useLayoutEffect; +let serverHandoffComplete = false; +let id = 0; +const genId = () => ++id; +export function useUniqueId(idFromProps?: string | null) { + /* + * If this instance isn't part of the initial render, we don't have to do the + * double render/patch-up dance. We can just generate the ID and return it. + */ + const initialId = idFromProps || (serverHandoffComplete ? genId() : null); + + const [id, setId] = useState(initialId); + + useIsomorphicLayoutEffect(() => { + if (id === null) { + /* + * Patch the ID after render. We do this in `useLayoutEffect` to avoid any + * rendering flicker, though it'll make the first render slower (unlikely + * to matter, but you're welcome to measure your app and let us know if + * it's a problem). + */ + setId(genId()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (serverHandoffComplete === false) { + /* + * Flag all future uses of `useId` to skip the update dance. This is in + * `useEffect` because it goes after `useLayoutEffect`, ensuring we don't + * accidentally bail out of the patch-up dance prematurely. + */ + serverHandoffComplete = true; + } + }, []); + return id != null ? String(id) : undefined; } export function usePaddingWarning(element: RefObject): void {