fix(map): prevent controllable state loops
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
import { useControllableObjectState } from "./useControllableState";
|
||||
|
||||
describe("useControllableObjectState", () => {
|
||||
it("keeps controlled setters stable when the external value changes", () => {
|
||||
const onChange = jest.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
({ value }: { value: { count: number } }) =>
|
||||
useControllableObjectState(value, onChange, { count: 0 }),
|
||||
{
|
||||
initialProps: { value: { count: 0 } },
|
||||
},
|
||||
);
|
||||
|
||||
const initialSetValue = result.current[1];
|
||||
const initialSetField = result.current[2];
|
||||
|
||||
act(() => {
|
||||
result.current[2]("count", 1);
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith({ count: 1 });
|
||||
|
||||
rerender({ value: { count: 1 } });
|
||||
|
||||
expect(result.current[1]).toBe(initialSetValue);
|
||||
expect(result.current[2]).toBe(initialSetField);
|
||||
});
|
||||
|
||||
it("does not publish unchanged object fields", () => {
|
||||
const onChange = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useControllableObjectState({ count: 1 }, onChange, { count: 0 }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[2]("count", 1);
|
||||
});
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,14 @@ export const useControllableState = <T,>(
|
||||
const [internalValue, setInternalValue] = useState<T>(defaultValue);
|
||||
const value = externalValue !== undefined ? externalValue : internalValue;
|
||||
const valueRef = useRef(value);
|
||||
const isControlledRef = useRef(externalValue !== undefined);
|
||||
const onExternalChangeRef = useRef(onExternalChange);
|
||||
|
||||
useEffect(() => {
|
||||
valueRef.current = value;
|
||||
}, [value]);
|
||||
isControlledRef.current = externalValue !== undefined;
|
||||
onExternalChangeRef.current = onExternalChange;
|
||||
}, [externalValue, onExternalChange, value]);
|
||||
|
||||
const setValue = useCallback(
|
||||
(next: T | ((previous: T) => T)) => {
|
||||
@@ -21,13 +25,18 @@ export const useControllableState = <T,>(
|
||||
typeof next === "function"
|
||||
? (next as (previous: T) => T)(valueRef.current)
|
||||
: next;
|
||||
|
||||
if (Object.is(valueRef.current, nextValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
valueRef.current = nextValue;
|
||||
if (externalValue === undefined) {
|
||||
if (!isControlledRef.current) {
|
||||
setInternalValue(nextValue);
|
||||
}
|
||||
onExternalChange?.(nextValue);
|
||||
onExternalChangeRef.current?.(nextValue);
|
||||
},
|
||||
[externalValue, onExternalChange],
|
||||
[],
|
||||
);
|
||||
|
||||
return [value, setValue] as const;
|
||||
@@ -46,7 +55,11 @@ export const useControllableObjectState = <T extends object>(
|
||||
|
||||
const setField = useCallback(
|
||||
<K extends keyof T>(key: K, nextValue: T[K]) => {
|
||||
setValue((previous) => ({ ...previous, [key]: nextValue }));
|
||||
setValue((previous) =>
|
||||
Object.is(previous[key], nextValue)
|
||||
? previous
|
||||
: { ...previous, [key]: nextValue },
|
||||
);
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user