fix(map): prevent controllable state loops
Build Push and Deploy / docker-image (push) Successful in 55s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped

This commit is contained in:
2026-07-09 14:50:33 +08:00
parent 694f7629ee
commit 14c76231d5
2 changed files with 60 additions and 5 deletions
@@ -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],
);