From 9a3e6d13fc30d799dbde49209b94288174715cf3 Mon Sep 17 00:00:00 2001 From: ivy-edp Date: Wed, 15 Jul 2026 14:36:48 +0200 Subject: [PATCH] XIVY-19301 Persist useOnFocus value on unmount --- .../src/components/browser/useOnFocus.test.ts | 58 +++++++++++++++++++ .../src/components/browser/useOnFocus.ts | 28 ++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 packages/inscription-view/src/components/browser/useOnFocus.test.ts diff --git a/packages/inscription-view/src/components/browser/useOnFocus.test.ts b/packages/inscription-view/src/components/browser/useOnFocus.test.ts new file mode 100644 index 000000000..c9aacb9b5 --- /dev/null +++ b/packages/inscription-view/src/components/browser/useOnFocus.test.ts @@ -0,0 +1,58 @@ +import { StrictMode } from 'react'; +import { act, renderHook } from 'test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import { useOnFocus } from './useOnFocus'; + +type FocusWithinCallbacks = { + onFocusWithin?: () => void; + onBlurWithin?: () => void; +}; + +const state = vi.hoisted(() => ({ callbacks: {} as FocusWithinCallbacks })); + +vi.mock('react-aria', () => ({ + useFocusWithin: (callbacks: FocusWithinCallbacks) => { + state.callbacks.onFocusWithin = callbacks.onFocusWithin; + state.callbacks.onBlurWithin = callbacks.onBlurWithin; + return { focusWithinProps: {} }; + } +})); + +describe('useOnFocus', () => { + test('commits latest value once when unmounted before blur', () => { + const onChange = vi.fn(); + const { result, unmount } = renderHook(() => useOnFocus('init', onChange)); + + act(() => state.callbacks.onFocusWithin?.()); + act(() => result.current.focusValue.onChange('edited value')); + + unmount(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('edited value'); + }); + + test('does not double-commit after a normal blur commit', () => { + const onChange = vi.fn(); + const { result, unmount } = renderHook(() => useOnFocus('init', onChange)); + + act(() => state.callbacks.onFocusWithin?.()); + act(() => result.current.focusValue.onChange('edited value')); + act(() => state.callbacks.onBlurWithin?.()); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('edited value'); + unmount(); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + test('does not call onChange on initial mount or StrictMode lifecycle simulation', () => { + const onChange = vi.fn(); + const { unmount } = renderHook(() => useOnFocus('init', onChange), { + wrapper: StrictMode + }); + + expect(onChange).not.toHaveBeenCalled(); + unmount(); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/inscription-view/src/components/browser/useOnFocus.ts b/packages/inscription-view/src/components/browser/useOnFocus.ts index fccc26813..6e6ff795b 100644 --- a/packages/inscription-view/src/components/browser/useOnFocus.ts +++ b/packages/inscription-view/src/components/browser/useOnFocus.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useFocusWithin } from 'react-aria'; import { useBrowser } from './useBrowser'; @@ -15,18 +15,42 @@ export const useOnFocus = ( const [focusValue, setFocusValue] = useState(initialValue); const [prevFocusValue, setPrevFocusValue] = useState(initialValue); const browser = useBrowser(); + const latestFocusValueRef = useRef(focusValue); + const latestOnChangeRef = useRef(onChange); + const isFocusWithinRef = useRef(isFocusWithin); + const lastCommittedValueRef = useRef(initialValue); + + latestFocusValueRef.current = focusValue; + latestOnChangeRef.current = onChange; + isFocusWithinRef.current = isFocusWithin; + + useEffect(() => { + return () => { + const hasUncommittedChange = latestFocusValueRef.current !== lastCommittedValueRef.current; + if (isFocusWithinRef.current && hasUncommittedChange) { + latestOnChangeRef.current(latestFocusValueRef.current); + } + }; + }, []); + const { focusWithinProps } = useFocusWithin({ - onFocusWithin: () => setIsFocusWithin(true), + onFocusWithin: () => { + isFocusWithinRef.current = true; + setIsFocusWithin(true); + }, onBlurWithin: () => { if (!browser.open) { + isFocusWithinRef.current = false; setIsFocusWithin(false); onChange(focusValue); + lastCommittedValueRef.current = focusValue; } } }); if (initialValue !== prevFocusValue) { setFocusValue(initialValue); setPrevFocusValue(initialValue); + lastCommittedValueRef.current = initialValue; } return { isFocusWithin, focusWithinProps, focusValue: { value: focusValue, onChange: setFocusValue }, browser }; };