Skip to content

Commit 8ee9b93

Browse files
committed
main 🧊 add use batched callback, add tests
1 parent e8a7913 commit 8ee9b93

File tree

20 files changed

+817
-268
lines changed

20 files changed

+817
-268
lines changed

‎packages/core/src/bundle/helpers/createStore/createStore.js‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useSyncExternalStore } from 'react';
1717
*/
1818
export const createStore = (createState) => {
1919
let state;
20+
let initialState;
2021
const listeners = new Set();
2122
const setState = (action) => {
2223
const nextState = typeof action === 'function' ? action(state) : action;
@@ -34,11 +35,11 @@ export const createStore = (createState) => {
3435
return () => listeners.delete(listener);
3536
};
3637
const getState = () => state;
37-
const getInitialState = () => state;
38+
const getInitialState = () => initialState;
3839
if (typeof createState === 'function') {
39-
state = createState(setState, getState);
40+
initialState = state = createState(setState, getState);
4041
} else {
41-
state = createState;
42+
initialState = state = createState;
4243
}
4344
function useStore(selector) {
4445
return useSyncExternalStore(
@@ -50,6 +51,7 @@ export const createStore = (createState) => {
5051
return {
5152
set: setState,
5253
get: getState,
54+
getInitial: getInitialState,
5355
use: useStore,
5456
subscribe
5557
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useMemo, useRef } from 'react';
2+
/**
3+
* @name useBatchedCallback
4+
* @description - Hook that batches calls and forwards them to a callback
5+
* @category Utilities
6+
* @usage medium
7+
*
8+
* @template Params The type of the params
9+
* @param {(batch: Params[]) => void} callback The callback that receives a batch of calls
10+
* @param {number} batchSize The maximum size of a batch before it is flushed
11+
* @returns {BatchedCallback<Params>} The batched callback with flush and cancel helpers
12+
*
13+
* @example
14+
* const batched = useBatchedCallback((batch) => console.log(batch), 5);
15+
*/
16+
export const useBatchedCallback = (callback, size) => {
17+
const callbackRef = useRef(callback);
18+
const sizeRef = useRef(size);
19+
const queueRef = useRef([]);
20+
callbackRef.current = callback;
21+
sizeRef.current = size;
22+
const flush = () => {
23+
if (!queueRef.current.length) return;
24+
const batch = queueRef.current;
25+
queueRef.current = [];
26+
callbackRef.current(batch);
27+
};
28+
const batched = useMemo(() => {
29+
const batchedCallback = (...args) => {
30+
queueRef.current.push(args);
31+
if (queueRef.current.length >= sizeRef.current) flush();
32+
};
33+
batchedCallback.flush = flush;
34+
batchedCallback.cancel = () => (queueRef.current = []);
35+
return batchedCallback;
36+
}, []);
37+
return batched;
38+
};

‎packages/core/src/bundle/hooks/useOtpCredential/useOtpCredential.js‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRef, useState } from 'react';
1+
import { useRef } from 'react';
22
/**
33
* @name useOtpCredential
44
* @description - Hook that creates an otp credential
@@ -25,9 +25,12 @@ import { useRef, useState } from 'react';
2525
export const useOtpCredential = (...params) => {
2626
const supported =
2727
typeof navigator !== 'undefined' && 'OTPCredential' in navigator && !!navigator.OTPCredential;
28-
const onSuccess = typeof params[0] === 'function' ? params[0] : params[0]?.onSuccess;
29-
const onError = typeof params[0] === 'function' ? params[0]?.onError : undefined;
30-
const [aborted, setAborted] = useState(false);
28+
const options =
29+
typeof params[0] === 'object'
30+
? params[0]
31+
: {
32+
onSuccess: params[0]
33+
};
3134
const abortControllerRef = useRef(new AbortController());
3235
const get = async () => {
3336
if (!supported) return;
@@ -37,17 +40,15 @@ export const useOtpCredential = (...params) => {
3740
otp: { transport: ['sms'] },
3841
signal: abortControllerRef.current.signal
3942
});
40-
onSuccess?.(credential);
41-
setAborted(false);
43+
options.onSuccess?.(credential);
4244
return credential;
4345
} catch (error) {
44-
onError?.(error);
46+
options.onError?.(error);
4547
}
4648
};
4749
const abort = () => {
4850
abortControllerRef.current.abort();
4951
abortControllerRef.current = new AbortController();
50-
abortControllerRef.current.signal.onabort = () => setAborted(true);
5152
};
52-
return { supported, abort, aborted, get };
53+
return { supported, abort, get };
5354
};

‎packages/core/src/bundle/hooks/useSpeechRecognition/useSpeechRecognition.js‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const useSpeechRecognition = (options = {}) => {
4141
const [final, setFinal] = useState(false);
4242
const [error, setError] = useState(null);
4343
const [recognition] = useState(() => {
44-
if (!supported) return {};
44+
if (!supported) return undefined;
4545
const SpeechRecognition = getSpeechRecognition();
4646
const speechRecognition = new SpeechRecognition();
4747
speechRecognition.continuous = continuous;
@@ -54,10 +54,6 @@ export const useSpeechRecognition = (options = {}) => {
5454
setFinal(false);
5555
onStart?.();
5656
};
57-
speechRecognition.onend = () => {
58-
setListening(false);
59-
onEnd?.();
60-
};
6157
speechRecognition.onerror = (event) => {
6258
setError(event);
6359
setListening(false);
@@ -66,19 +62,21 @@ export const useSpeechRecognition = (options = {}) => {
6662
speechRecognition.onresult = (event) => {
6763
const currentResult = event.results[event.resultIndex];
6864
const { transcript } = currentResult[0];
65+
setListening(false);
6966
setTranscript(transcript);
7067
setError(null);
7168
onResult?.(event);
7269
};
7370
speechRecognition.onend = () => {
7471
setListening(false);
72+
onEnd?.();
7573
speechRecognition.lang = language;
7674
};
7775
return speechRecognition;
7876
});
79-
useEffect(() => () => recognition.stop(), []);
80-
const start = () => recognition.start();
81-
const stop = () => recognition.stop();
77+
useEffect(() => () => recognition?.stop(), []);
78+
const start = () => recognition?.start();
79+
const stop = () => recognition?.stop();
8280
const toggle = (value = !listening) => {
8381
if (value) return start();
8482
stop();

‎packages/core/src/bundle/hooks/useTextareaAutosize/useTextareaAutosize.js‎

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ import { useRefState } from '../useRefState/useRefState';
1717
* const { value, setValue, clear } = useTextareaAutosize(ref);
1818
*
1919
* @overload
20+
* @param {HookTarget} target The target textarea element
21+
* @param {string} initialValue The initial value for the textarea
22+
* @returns {UseTextareaAutosizeReturn} An object containing value, setValue and clear
23+
*
24+
* @example
25+
* const { value, setValue, clear } = useTextareaAutosize(ref, 'initial');
26+
*
27+
* @overload
2028
* @template Target The textarea element type
2129
* @param {string} initialValue The initial value for the textarea
2230
* @returns {UseTextareaAutosizeReturn & { ref: StateRef<Target> }} An object containing ref, value, setValue and clear
@@ -36,13 +44,16 @@ import { useRefState } from '../useRefState/useRefState';
3644
export const useTextareaAutosize = (...params) => {
3745
const target = isTarget(params[0]) ? params[0] : undefined;
3846
const options = target
39-
? params[1]
40-
: typeof params[0] === 'string'
41-
? { initialValue: params[0] }
42-
: params[0];
47+
? typeof params[1] === 'object'
48+
? params[1]
49+
: { initialValue: params[1] }
50+
: typeof params[0] === 'object'
51+
? params[0]
52+
: { initialValue: params[0] };
4353
const [value, setValue] = useState(options?.initialValue ?? '');
4454
const internalRef = useRefState();
4555
const textareaRef = useRef(null);
56+
const scrollHeightRef = useRef(0);
4657
const onTextareaResize = () => {
4758
const textarea = textareaRef.current;
4859
if (!textarea) return;
@@ -55,7 +66,17 @@ export const useTextareaAutosize = (...params) => {
5566
textarea.style.height = `${scrollHeight}px`;
5667
textarea.style.minHeight = originalMinHeight;
5768
textarea.style.maxHeight = originalMaxHeight;
58-
options?.onResize?.();
69+
if (scrollHeight !== scrollHeightRef.current) options?.onResize?.();
70+
scrollHeightRef.current = scrollHeight;
71+
};
72+
const setTextareaValue = (newValue) => {
73+
setValue(newValue);
74+
const textarea = textareaRef.current;
75+
if (!textarea) return;
76+
textarea.value = newValue;
77+
requestAnimationFrame(() => {
78+
onTextareaResize();
79+
});
5980
};
6081
useEffect(() => {
6182
if (!target && !internalRef.state) return;
@@ -66,7 +87,7 @@ export const useTextareaAutosize = (...params) => {
6687
onTextareaResize();
6788
const onInput = (event) => {
6889
const newValue = event.target.value;
69-
setValue(newValue);
90+
setTextareaValue(newValue);
7091
requestAnimationFrame(() => {
7192
onTextareaResize();
7293
});
@@ -82,36 +103,18 @@ export const useTextareaAutosize = (...params) => {
82103
element.removeEventListener('input', onInput);
83104
element.removeEventListener('resize', onResize);
84105
};
85-
}, [target, internalRef.state, isTarget.getRefState(target), options?.initialValue]);
86-
useEffect(() => {
87-
const textarea = textareaRef.current;
88-
if (!textarea) return;
89-
textarea.value = value;
90-
requestAnimationFrame(() => {
91-
onTextareaResize();
92-
});
93-
}, [value]);
94-
const setTextareaValue = (newValue) => {
95-
setValue(newValue);
96-
const textarea = textareaRef.current;
97-
if (textarea) {
98-
textarea.value = newValue;
99-
requestAnimationFrame(() => {
100-
onTextareaResize();
101-
});
102-
}
103-
};
106+
}, [target, internalRef.state, isTarget.getRefState(target)]);
104107
const clear = () => setValue('');
105108
if (target)
106109
return {
107110
value,
108-
setValue: setTextareaValue,
111+
set: setTextareaValue,
109112
clear
110113
};
111114
return {
112115
ref: internalRef,
113116
value,
114-
setValue: setTextareaValue,
117+
set: setTextareaValue,
115118
clear
116119
};
117120
};

‎packages/core/src/bundle/hooks/utilities.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './useBatchedCallback/useBatchedCallback';
12
// base
23
export * from './useConst/useConst';
34
// timing

‎packages/core/src/helpers/createStore/createStore.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,9 @@ it('Should work correct with array state', () => {
134134
});
135135

136136
it('Should return initial state after state changes', () => {
137-
const counterStore = createStore<number>(0)
137+
const store = createStore<{ count: number }>({ count: 0 });
138138

139-
counterStore.set(1)
139+
store.set({ count: 1 });
140140

141-
expect(store.getInitialState()).toEqual(0)
142-
})
141+
expect(store.getInitial()).toEqual({ count: 0 });
142+
});

‎packages/core/src/helpers/createStore/createStore.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ type StoreCreator<Value> = (
1010
) => Value;
1111

1212
export interface StoreApi<Value> {
13-
getInitialState: () => Value;
1413
get: () => Value;
14+
getInitial: () => Value;
1515
set: (action: StoreSetAction<Value>) => void;
1616
subscribe: (listener: StoreListener<Value>) => () => void;
1717

18-
use(): Value;
19-
use<Selected>(selector: (state: Value) => Selected): Selected;
20-
use<Selected>(selector?: (state: Value) => Selected): Value | Selected;
18+
use: (() => Value) &
19+
(<Selected>(selector: (state: Value) => Selected) => Selected) &
20+
(<Selected>(selector?: (state: Value) => Selected) => Selected | Value);
2121
}
2222

2323
/**
@@ -84,7 +84,7 @@ export const createStore = <Value>(createState: StoreCreator<Value> | Value): St
8484
return {
8585
set: setState,
8686
get: getState,
87-
getInitialState,
87+
getInitial: getInitialState,
8888
use: useStore,
8989
subscribe
9090
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useBatchedCallback, useCounter } from '@siberiacancode/reactuse';
2+
import { useState } from 'react';
3+
4+
const Demo = () => {
5+
const [batches, setBatches] = useState<number[][]>([]);
6+
const [currentBatch, setCurrentBatch] = useState<number[]>([]);
7+
const counter = useCounter(0);
8+
9+
const batchedNumbers = useBatchedCallback((batch: [number][]) => {
10+
const numbers = batch.map(([num]) => num);
11+
setBatches((currentBatches) => [...currentBatches, numbers]);
12+
counter.inc(numbers.reduce((acc, number) => acc + number, 0));
13+
setCurrentBatch([]);
14+
}, 5);
15+
16+
const onAdd = () => {
17+
const random = Math.floor(Math.random() * 100);
18+
setCurrentBatch((currentBatch) => (currentBatch.length >= 5 ? [] : [...currentBatch, random]));
19+
batchedNumbers(random);
20+
};
21+
22+
return (
23+
<>
24+
<div className='mb-4 flex flex-col gap-2'>
25+
<label>Batched random numbers (flush every 5 adds)</label>
26+
</div>
27+
28+
<div className='text-muted-foreground text-xs'>
29+
Last batch: {batches.at(-1)?.join(', ') ?? '—'}
30+
</div>
31+
<div className='text-muted-foreground text-xs'>
32+
Current batch: {currentBatch.length ? currentBatch.join(', ') : '—'}
33+
</div>
34+
35+
<button className='mt-4 rounded px-3 py-2 text-white' type='button' onClick={onAdd}>
36+
Add random
37+
</button>
38+
<div className='flex gap-4 text-sm'>
39+
<div>Total sum: {counter.value}</div>
40+
<div>Total batches: {batches.length}</div>
41+
</div>
42+
</>
43+
);
44+
};
45+
46+
export default Demo;

0 commit comments

Comments
 (0)