diff --git a/.changeset/brave-rabbits-glow.md b/.changeset/brave-rabbits-glow.md new file mode 100644 index 000000000..80c503bde --- /dev/null +++ b/.changeset/brave-rabbits-glow.md @@ -0,0 +1,5 @@ +--- +'@radix-ui/react-one-time-password-field': patch +--- + +Fixed OTP field dispatch using stale value/collection state in React 19.2. diff --git a/packages/react/one-time-password-field/src/one-time-password-field.test.tsx b/packages/react/one-time-password-field/src/one-time-password-field.test.tsx index 5e28c58a2..9523fe5a3 100644 --- a/packages/react/one-time-password-field/src/one-time-password-field.test.tsx +++ b/packages/react/one-time-password-field/src/one-time-password-field.test.tsx @@ -68,6 +68,30 @@ describe('given a default OneTimePasswordField', () => { }); }); + it('should type digits and advance focus', async () => { + const inputs = screen.getAllByRole('textbox', { hidden: false }); + await user.click(inputs[0]!); + await user.keyboard('1'); + expect(inputs[0]!.value).toBe('1'); + // focus should have moved to second input + expect(document.activeElement).toBe(inputs[1]); + await user.keyboard('2'); + expect(inputs[1]!.value).toBe('2'); + expect(document.activeElement).toBe(inputs[2]); + }); + + it('should navigate with arrow keys', async () => { + const inputs = screen.getAllByRole('textbox', { hidden: false }); + await user.click(inputs[0]!); + await user.keyboard('1'); + // now on input 1 + expect(document.activeElement).toBe(inputs[1]); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(inputs[0]); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(inputs[1]); + }); + // TODO: userEvent paste not behaving as expected. Debug and unskip. // Replicated in storybook for now. it.todo('pastes the code into the input', async () => { diff --git a/packages/react/one-time-password-field/src/one-time-password-field.tsx b/packages/react/one-time-password-field/src/one-time-password-field.tsx index 0f1065bf6..8e6145df2 100644 --- a/packages/react/one-time-password-field/src/one-time-password-field.tsx +++ b/packages/react/one-time-password-field/src/one-time-password-field.tsx @@ -12,7 +12,6 @@ import type { Scope } from '@radix-ui/react-context'; import { createContextScope } from '@radix-ui/react-context'; import { useDirection } from '@radix-ui/react-direction'; import { clamp } from '@radix-ui/number'; -import { useEffectEvent } from '@radix-ui/react-use-effect-event'; type InputValidationType = 'alpha' | 'numeric' | 'alphanumeric' | 'none'; @@ -265,8 +264,15 @@ const OneTimePasswordField = React.forwardRef((action) => { + const dispatch = React.useCallback((action) => { + const value = latestValueRef.current; switch (action.type) { case 'SET_CHAR': { const { index, char } = action; @@ -361,7 +367,7 @@ const OneTimePasswordField = React.forwardRef