diff --git a/src/components/text-input/text-input.test.tsx b/src/components/text-input/text-input.test.tsx index b8d02eb033..cc0e35987f 100644 --- a/src/components/text-input/text-input.test.tsx +++ b/src/components/text-input/text-input.test.tsx @@ -1,5 +1,6 @@ import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; +import { createRef } from 'react'; import { InputType, TextInput } from './text-input'; const testInputType = (type: InputType) => (): void => { @@ -76,4 +77,125 @@ describe('', () => { const textInput = screen.getByTestId('textInput'); expect(textInput).toHaveClass('a-text-input--full'); }); + + it('attaches inputRef to the underlying input', () => { + const inputRef = createRef(); + render(); + + expect(inputRef.current).toBe(screen.getByTestId('textInput')); + }); + + it('supports callback inputRef', () => { + let node: HTMLInputElement | null = null; + render( + { + node = element; + }} + />, + ); + + expect(node).toBe(screen.getByTestId('textInput')); + }); + + it('attaches both inputRef and forwarded ref', () => { + const inputRef = createRef(); + const forwardedRef = createRef(); + render( + , + ); + + const textInput = screen.getByTestId('textInput'); + expect(inputRef.current).toBe(textInput); + expect(forwardedRef.current).toBe(textInput); + }); + + it('keeps a stable inputRef attached across rerenders', () => { + const calls: (HTMLInputElement | null)[] = []; + const inputRef = (element: HTMLInputElement | null): void => { + calls.push(element); + }; + const { rerender } = render( + , + ); + + rerender( + , + ); + + expect(calls).toEqual([screen.getByTestId('textInput')]); + }); + + it('clears object refs on unmount', () => { + const inputRef = createRef(); + const forwardedRef = createRef(); + const { unmount } = render( + , + ); + + unmount(); + + expect(inputRef.current).toBeNull(); + expect(forwardedRef.current).toBeNull(); + }); + + it('runs ref cleanups on unmount', () => { + const calls: string[] = []; + const { unmount } = render( + { + if (!element) return; + calls.push('input-attach'); + return () => { + calls.push('input-cleanup'); + }; + }} + ref={(element) => { + if (!element) return; + calls.push('forwarded-attach'); + return () => { + calls.push('forwarded-cleanup'); + }; + }} + />, + ); + + expect(calls).toEqual(['input-attach', 'forwarded-attach']); + unmount(); + expect(calls).toEqual([ + 'input-attach', + 'forwarded-attach', + 'input-cleanup', + 'forwarded-cleanup', + ]); + }); + + it('calls a callback inputRef with null on unmount when it returns no cleanup', () => { + const calls: (HTMLInputElement | null)[] = []; + const inputRef = (element: HTMLInputElement | null): void => { + calls.push(element); + }; + const { unmount } = render( + , + ); + + unmount(); + + expect(calls.at(-1)).toBeNull(); + expect(calls[0]).toBeInstanceOf(HTMLInputElement); + }); }); diff --git a/src/components/text-input/text-input.tsx b/src/components/text-input/text-input.tsx index 5193d2183a..9ea9a6223b 100644 --- a/src/components/text-input/text-input.tsx +++ b/src/components/text-input/text-input.tsx @@ -2,14 +2,37 @@ import classnames from 'classnames'; import { forwardRef, JSX, + useCallback, type ComponentPropsWithoutRef, - type ReactNode, + type Ref, } from 'react'; import './cfpb-text-input.scss'; import type { TextInputStatusType } from './text-input-status'; import { getTextInputStatusClass } from './text-input-status'; -type TextInputReference = ReactNode; +type TextInputReference = Ref; + +const releaseNothing = (): void => undefined; + +const bindReference = ( + target: Ref | undefined, + element: HTMLInputElement | null, +): (() => void) => { + if (typeof target === 'function') { + const cleanup = target(element); + return (): void => { + if (typeof cleanup === 'function') cleanup(); + else target(null); + }; + } + if (target) { + target.current = element; + return (): void => { + target.current = null; + }; + } + return releaseNothing; +}; export type InputType = 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url'; @@ -42,7 +65,7 @@ export const TextInput = forwardRef( { className, id, - inputRef: _inputReference, + inputRef, isDisabled = false, name, status, @@ -62,6 +85,18 @@ export const TextInput = forwardRef( classes.push('a-text-input--full'); } + const setReferences = useCallback( + (element: HTMLInputElement | null): (() => void) => { + const releaseInputReference = bindReference(inputRef, element); + const releaseForwardedReference = bindReference(reference, element); + return (): void => { + releaseInputReference(); + releaseForwardedReference(); + }; + }, + [inputRef, reference], + ); + const input = ( ( id={id} name={name} type={type} - ref={reference} + ref={setReferences} {...otherInputProperties} /> );