Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
});
});
28 changes: 26 additions & 2 deletions packages/inscription-view/src/components/browser/useOnFocus.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useFocusWithin } from 'react-aria';
import { useBrowser } from './useBrowser';

Expand All @@ -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);
Comment thread
ivy-edp marked this conversation as resolved.
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);
}
};
}, []);
Comment thread
ivy-edp marked this conversation as resolved.
Comment thread
ivy-edp marked this conversation as resolved.

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 };
};