From c16526c55feea7f7bd919255b3cbba5b2d81c5b7 Mon Sep 17 00:00:00 2001 From: soroush5 Date: Wed, 23 Sep 2026 17:40:00 +0330 Subject: [PATCH 1/2] fix(TextInput): wire inputRef to the underlying input --- src/components/text-input/text-input.test.tsx | 40 +++++++++++++++++++ src/components/text-input/text-input.tsx | 21 ++++++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/components/text-input/text-input.test.tsx b/src/components/text-input/text-input.test.tsx index b8d02eb033..8c05351c35 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,43 @@ 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); + }); }); diff --git a/src/components/text-input/text-input.tsx b/src/components/text-input/text-input.tsx index 5193d2183a..00e66aebe6 100644 --- a/src/components/text-input/text-input.tsx +++ b/src/components/text-input/text-input.tsx @@ -3,13 +3,21 @@ import { forwardRef, JSX, 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 assignReference = ( + target: Ref | undefined, + element: HTMLInputElement | null, +): void => { + if (typeof target === 'function') target(element); + else if (target) target.current = element; +}; export type InputType = 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url'; @@ -42,7 +50,7 @@ export const TextInput = forwardRef( { className, id, - inputRef: _inputReference, + inputRef, isDisabled = false, name, status, @@ -62,6 +70,11 @@ export const TextInput = forwardRef( classes.push('a-text-input--full'); } + const setReferences = (element: HTMLInputElement | null): void => { + assignReference(inputRef, element); + assignReference(reference, element); + }; + const input = ( ( id={id} name={name} type={type} - ref={reference} + ref={setReferences} {...otherInputProperties} /> ); From bdf452af5ce7cd68683154858ee57e31b994d28b Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:41:11 -0700 Subject: [PATCH 2/2] update TextInput so the merged ref stays stable and forwards React 19 cleanups add tests for rerender and unmount. --- src/components/text-input/text-input.test.tsx | 82 +++++++++++++++++++ src/components/text-input/text-input.tsx | 38 +++++++-- 2 files changed, 112 insertions(+), 8 deletions(-) diff --git a/src/components/text-input/text-input.test.tsx b/src/components/text-input/text-input.test.tsx index 8c05351c35..cc0e35987f 100644 --- a/src/components/text-input/text-input.test.tsx +++ b/src/components/text-input/text-input.test.tsx @@ -116,4 +116,86 @@ describe('', () => { 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 00e66aebe6..9ea9a6223b 100644 --- a/src/components/text-input/text-input.tsx +++ b/src/components/text-input/text-input.tsx @@ -2,6 +2,7 @@ import classnames from 'classnames'; import { forwardRef, JSX, + useCallback, type ComponentPropsWithoutRef, type Ref, } from 'react'; @@ -11,12 +12,26 @@ import { getTextInputStatusClass } from './text-input-status'; type TextInputReference = Ref; -const assignReference = ( +const releaseNothing = (): void => undefined; + +const bindReference = ( target: Ref | undefined, element: HTMLInputElement | null, -): void => { - if (typeof target === 'function') target(element); - else if (target) target.current = element; +): (() => 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 = @@ -70,10 +85,17 @@ export const TextInput = forwardRef( classes.push('a-text-input--full'); } - const setReferences = (element: HTMLInputElement | null): void => { - assignReference(inputRef, element); - assignReference(reference, element); - }; + 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 = (