Skip to content

Commit 926c8ec

Browse files
fix: apply batched functional updates in useControllableState (#80)
1 parent 2fcd56a commit 926c8ec

3 files changed

Lines changed: 67 additions & 6 deletions

File tree

‎src/hooks/use-controllable-state.test.ts‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2-
import { act, createElement } from "react";
2+
import { StrictMode, act, createElement } from "react";
33
import { createRoot } from "react-dom/client";
44

55
import {
66
useControllableState,
77
type UseControllableStateOptions,
88
} from "./use-controllable-state.js";
9+
import { renderHook } from "../test/render-hook.js";
910

1011
function mount<T>(options: UseControllableStateOptions<T>) {
1112
const container = document.createElement("div");
@@ -52,6 +53,45 @@ describe("useControllableState", () => {
5253
handle.unmount();
5354
});
5455

56+
test("uncontrolled: batched functional updates each see the previous result", () => {
57+
const changes: number[] = [];
58+
const { result, unmount } = renderHook(() =>
59+
useControllableState({
60+
defaultValue: 0,
61+
name: "test",
62+
onChange: (v) => changes.push(v),
63+
}),
64+
);
65+
act(() => {
66+
result.current[1]((previous) => previous + 1);
67+
result.current[1]((previous) => previous + 1);
68+
});
69+
expect(result.current[0]).toBe(2);
70+
expect(changes).toEqual([1, 2]);
71+
unmount();
72+
});
73+
74+
test("uncontrolled: onChange fires once per update under StrictMode", () => {
75+
const changes: number[] = [];
76+
const container = document.createElement("div");
77+
const root = createRoot(container);
78+
let set: ((action: (previous: number) => number) => void) | undefined;
79+
function Host() {
80+
set = useControllableState({
81+
defaultValue: 0,
82+
name: "test",
83+
onChange: (v) => changes.push(v),
84+
})[1];
85+
return null;
86+
}
87+
act(() =>
88+
root.render(createElement(StrictMode, null, createElement(Host))),
89+
);
90+
act(() => set?.((previous) => previous + 1));
91+
expect(changes).toEqual([1]);
92+
act(() => root.unmount());
93+
});
94+
5595
test("controlled: the value tracks the prop, not internal state", () => {
5696
let changed: boolean | undefined;
5797
const handle = mount({

‎src/hooks/use-controllable-state.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,27 @@ export function useControllableState<T>(
5252
}
5353
wasControlledRef.current = isControlled;
5454

55+
// Mirrors the uncontrolled state synchronously so batched functional updates
56+
// chain off each other. Resolving inside a React updater instead would call
57+
// `onChange` from that updater, which StrictMode invokes twice.
58+
const latestUncontrolledRef = useRef(uncontrolledValue);
59+
5560
const setValue = useCallback(
5661
(action: ControllableStateAction<T>) => {
57-
const resolved =
62+
const previous = isControlled
63+
? controlledValue
64+
: latestUncontrolledRef.current;
65+
const next =
5866
typeof action === "function"
59-
? (action as (previous: T) => T)(value)
67+
? (action as (previous: T) => T)(previous)
6068
: action;
61-
if (!isControlled) setUncontrolledValue(resolved);
62-
onChange?.(resolved);
69+
if (!isControlled) {
70+
latestUncontrolledRef.current = next;
71+
setUncontrolledValue(next);
72+
}
73+
onChange?.(next);
6374
},
64-
[isControlled, value, onChange],
75+
[isControlled, controlledValue, onChange],
6576
);
6677

6778
return [value, setValue] as const;

‎src/hooks/use-dismissable-popover.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ describe("useDismissablePopover", () => {
7373
handle.unmount();
7474
});
7575

76+
test("uncontrolled: two batched toggles return to closed", () => {
77+
const handle = mount();
78+
act(() => {
79+
handle.get().setOpen((previous) => !previous);
80+
handle.get().setOpen((previous) => !previous);
81+
});
82+
expect(handle.get().open).toBe(false);
83+
handle.unmount();
84+
});
85+
7686
test("uncontrolled: honors defaultOpen", () => {
7787
const handle = mount({ defaultOpen: true });
7888
expect(handle.get().open).toBe(true);

0 commit comments

Comments
 (0)