Skip to content

Commit e6a8a2d

Browse files
authored
refactor: useMergedState event logic (#315)
* refactor: onChange in sync * refacor: merge all * test: test case * test: more
1 parent 813f946 commit e6a8a2d

File tree

3 files changed

+198
-69
lines changed

3 files changed

+198
-69
lines changed

src/hooks/useEvent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ export default function useEvent<T extends Function>(callback: T): T {
99
[],
1010
);
1111

12-
return callback ? memoFn : undefined;
12+
return memoFn;
1313
}

src/hooks/useMergedState.ts

Lines changed: 80 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,28 @@ type Updater<T> = (
88
ignoreDestroy?: boolean,
99
) => void;
1010

11+
enum Source {
12+
INNER,
13+
PROP,
14+
}
15+
16+
type ValueRecord<T> = [T, Source, T];
17+
18+
const useUpdateEffect: typeof React.useEffect = (callback, deps) => {
19+
const [firstMount, setFirstMount] = React.useState(true);
20+
21+
useLayoutEffect(() => {
22+
if (!firstMount) {
23+
return callback();
24+
}
25+
}, deps);
26+
27+
// We tell react that first mount has passed
28+
useLayoutEffect(() => {
29+
setFirstMount(false);
30+
}, []);
31+
};
32+
1133
/**
1234
* Similar to `useState` but will use props value if provided.
1335
* Note that internal use rc-util `useState` hook.
@@ -22,53 +44,77 @@ export default function useMergedState<T, R = T>(
2244
},
2345
): [R, Updater<T>] {
2446
const { defaultValue, value, onChange, postState } = option || {};
25-
const [innerValue, setInnerValue] = useState<T>(() => {
47+
48+
// ======================= Init =======================
49+
const [mergedValue, setMergedValue] = useState<ValueRecord<T>>(() => {
50+
let finalValue: T = undefined;
51+
let source: Source;
52+
2653
if (value !== undefined) {
27-
return value;
28-
}
29-
if (defaultValue !== undefined) {
30-
return typeof defaultValue === 'function'
31-
? (defaultValue as any)()
32-
: defaultValue;
54+
finalValue = value;
55+
source = Source.PROP;
56+
} else if (defaultValue !== undefined) {
57+
finalValue =
58+
typeof defaultValue === 'function'
59+
? (defaultValue as any)()
60+
: defaultValue;
61+
source = Source.PROP;
62+
} else {
63+
finalValue =
64+
typeof defaultStateValue === 'function'
65+
? (defaultStateValue as any)()
66+
: defaultStateValue;
67+
source = Source.INNER;
3368
}
34-
return typeof defaultStateValue === 'function'
35-
? (defaultStateValue as any)()
36-
: defaultStateValue;
69+
70+
return [finalValue, source, finalValue];
3771
});
3872

39-
const mergedValue = value !== undefined ? value : innerValue;
40-
const postMergedValue = postState ? postState(mergedValue) : mergedValue;
73+
const postMergedValue = postState
74+
? postState(mergedValue[0])
75+
: mergedValue[0];
4176

42-
// setState
43-
const onChangeFn = useEvent(onChange);
77+
// ======================= Sync =======================
78+
useUpdateEffect(() => {
79+
setMergedValue(([prevValue]) => [value, Source.PROP, prevValue]);
80+
}, [value]);
4481

45-
const [changePrevValue, setChangePrevValue] = useState<T>();
82+
// ====================== Update ======================
83+
const changeEventPrevRef = React.useRef<T>();
4684

4785
const triggerChange: Updater<T> = useEvent((updater, ignoreDestroy) => {
48-
setChangePrevValue(mergedValue, true);
49-
setInnerValue(prev => {
50-
const nextValue =
51-
typeof updater === 'function' ? (updater as any)(prev) : updater;
52-
return nextValue;
86+
setMergedValue(prev => {
87+
const [prevValue, prevSource, prevPrevValue] = prev;
88+
89+
const nextValue: T =
90+
typeof updater === 'function' ? (updater as any)(prevValue) : updater;
91+
92+
// Do nothing if value not change
93+
if (nextValue === prevValue) {
94+
return prev;
95+
}
96+
97+
// Use prev prev value if is in a batch update to avoid missing data
98+
const overridePrevValue =
99+
prevSource === Source.INNER &&
100+
changeEventPrevRef.current !== prevPrevValue
101+
? prevPrevValue
102+
: prevValue;
103+
104+
return [nextValue, Source.INNER, overridePrevValue];
53105
}, ignoreDestroy);
54106
});
55107

56-
// Effect to trigger onChange
57-
useLayoutEffect(() => {
58-
if (changePrevValue !== undefined && changePrevValue !== innerValue) {
59-
onChangeFn?.(innerValue, changePrevValue);
60-
}
61-
}, [changePrevValue, innerValue, onChangeFn]);
108+
// ====================== Change ======================
109+
const onChangeFn = useEvent(onChange);
62110

63-
// Effect of reset value to `undefined`
64-
const prevValueRef = React.useRef(value);
65-
React.useEffect(() => {
66-
if (value === undefined && value !== prevValueRef.current) {
67-
setInnerValue(value);
111+
useLayoutEffect(() => {
112+
const [current, source, prev] = mergedValue;
113+
if (current !== prev && source === Source.INNER) {
114+
onChangeFn(current, prev);
115+
changeEventPrevRef.current = prev;
68116
}
69-
70-
prevValueRef.current = value;
71-
}, [value]);
117+
}, [mergedValue]);
72118

73119
return [postMergedValue as unknown as R, triggerChange];
74120
}

tests/hooks.test.js

Lines changed: 117 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,18 @@ describe('hooks', () => {
6767
expect(container.querySelector('input').value).toEqual('test');
6868
});
6969

70-
it('correct defaultValue', () => {
71-
const { container } = render(<FC defaultValue="test" />);
70+
describe('correct defaultValue', () => {
71+
it('raw', () => {
72+
const { container } = render(<FC defaultValue="test" />);
7273

73-
expect(container.querySelector('input').value).toEqual('test');
74+
expect(container.querySelector('input').value).toEqual('test');
75+
});
76+
77+
it('func', () => {
78+
const { container } = render(<FC defaultValue={() => 'bamboo'} />);
79+
80+
expect(container.querySelector('input').value).toEqual('bamboo');
81+
});
7482
});
7583

7684
it('not rerender when setState as deps', () => {
@@ -125,48 +133,123 @@ describe('hooks', () => {
125133
expect(container.querySelector('div').textContent).toEqual('2');
126134
});
127135

128-
it('not trigger onChange if props change', () => {
129-
const Demo = ({ value, onChange }) => {
130-
const [mergedValue, setValue] = useMergedState(0, {
136+
describe('not trigger onChange if props change', () => {
137+
function test(name, postWrapper = node => node) {
138+
it(name, () => {
139+
const Demo = ({ value, onChange }) => {
140+
const [mergedValue, setValue] = useMergedState(0, {
141+
onChange,
142+
});
143+
144+
return (
145+
<>
146+
<button
147+
onClick={() => {
148+
setValue(v => v + 1);
149+
}}
150+
>
151+
{mergedValue}
152+
</button>
153+
<a
154+
onClick={() => {
155+
setValue(v => v + 1);
156+
setValue(v => v + 1);
157+
}}
158+
/>
159+
</>
160+
);
161+
};
162+
163+
const onChange = jest.fn();
164+
const { container } = render(
165+
postWrapper(<Demo onChange={onChange} />),
166+
);
167+
168+
expect(container.querySelector('button').textContent).toEqual('0');
169+
expect(onChange).not.toHaveBeenCalled();
170+
171+
// Click to change
172+
fireEvent.click(container.querySelector('button'));
173+
expect(container.querySelector('button').textContent).toEqual('1');
174+
expect(onChange).toHaveBeenCalledWith(1, 0);
175+
onChange.mockReset();
176+
177+
// Click to change twice in same time so should not trigger onChange twice
178+
fireEvent.click(container.querySelector('a'));
179+
expect(container.querySelector('button').textContent).toEqual('3');
180+
expect(onChange).toHaveBeenCalledWith(3, 1);
181+
onChange.mockReset();
182+
});
183+
}
184+
185+
test('raw');
186+
test('strict', node => <React.StrictMode>{node}</React.StrictMode>);
187+
});
188+
189+
it('uncontrolled to controlled', () => {
190+
const onChange = jest.fn();
191+
192+
const Demo = ({ value }) => {
193+
const [mergedValue, setMergedValue] = useMergedState(() => 233, {
194+
value,
131195
onChange,
132196
});
133197

134198
return (
135-
<>
136-
<button
137-
onClick={() => {
138-
setValue(v => v + 1);
139-
}}
140-
>
141-
{mergedValue}
142-
</button>
143-
<a
144-
onClick={() => {
145-
setValue(v => v + 1);
146-
setValue(v => v + 1);
147-
}}
148-
/>
149-
</>
199+
<span
200+
onClick={() => {
201+
setMergedValue(v => v + 1);
202+
setMergedValue(v => v + 1);
203+
}}
204+
>
205+
{mergedValue}
206+
</span>
150207
);
151208
};
152209

153-
const onChange = jest.fn();
154-
const { container } = render(<Demo onChange={onChange} />);
155-
156-
expect(container.querySelector('button').textContent).toEqual('0');
210+
const { container, rerender } = render(<Demo />);
211+
expect(container.textContent).toEqual('233');
157212
expect(onChange).not.toHaveBeenCalled();
158213

159-
// Click to change
160-
fireEvent.click(container.querySelector('button'));
161-
expect(container.querySelector('button').textContent).toEqual('1');
162-
expect(onChange).toHaveBeenCalledWith(1, 0);
163-
onChange.mockReset();
214+
// Update value
215+
rerender(<Demo value={1} />);
216+
expect(container.textContent).toEqual('1');
217+
expect(onChange).not.toHaveBeenCalled();
164218

165-
// Click to change twice in same time so should not trigger onChange twice
166-
fireEvent.click(container.querySelector('a'));
167-
expect(container.querySelector('button').textContent).toEqual('3');
219+
// Click update
220+
fireEvent.click(container.querySelector('span'));
221+
expect(container.textContent).toEqual('3');
168222
expect(onChange).toHaveBeenCalledWith(3, 1);
169-
onChange.mockReset();
223+
});
224+
225+
it('not trigger onChange if set same value', () => {
226+
const onChange = jest.fn();
227+
228+
const Test = ({ value }) => {
229+
const [mergedValue, setMergedValue] = useMergedState(undefined, {
230+
value,
231+
onChange,
232+
});
233+
return (
234+
<span
235+
onClick={() => {
236+
setMergedValue(1);
237+
}}
238+
onMouseEnter={() => {
239+
setMergedValue(2);
240+
}}
241+
>
242+
{mergedValue}
243+
</span>
244+
);
245+
};
246+
247+
const { container } = render(<Test value={1} />);
248+
fireEvent.click(container.querySelector('span'));
249+
expect(onChange).not.toHaveBeenCalled();
250+
251+
fireEvent.mouseEnter(container.querySelector('span'));
252+
expect(onChange).toHaveBeenCalledWith(2, 1);
170253
});
171254
});
172255

0 commit comments

Comments
 (0)