Skip to content

Commit fa52467

Browse files
authored
[core] perf: avoid calling clearTimeout & cancelAnimationFrame (#1876)
1 parent 957fddf commit fa52467

26 files changed

+417
-291
lines changed

packages/react/src/avatar/fallback/AvatarFallback.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import * as React from 'react';
33
import { BaseUIComponentProps } from '../../utils/types';
44
import { useComponentRenderer } from '../../utils/useComponentRenderer';
5+
import { useTimeout } from '../../utils/useTimeout';
56
import { useAvatarRootContext } from '../root/AvatarRootContext';
67
import type { AvatarRoot } from '../root/AvatarRoot';
78
import { avatarStyleHookMapping } from '../root/styleHooks';
@@ -20,18 +21,14 @@ export const AvatarFallback = React.forwardRef(function AvatarFallback(
2021

2122
const { imageLoadingStatus } = useAvatarRootContext();
2223
const [delayPassed, setDelayPassed] = React.useState(delay === undefined);
24+
const timeout = useTimeout();
2325

2426
React.useEffect(() => {
25-
let timerId: number | undefined;
26-
2727
if (delay !== undefined) {
28-
timerId = window.setTimeout(() => setDelayPassed(true), delay);
28+
timeout.start(delay, () => setDelayPassed(true));
2929
}
30-
31-
return () => {
32-
window.clearTimeout(timerId);
33-
};
34-
}, [delay]);
30+
return timeout.clear;
31+
}, [timeout, delay]);
3532

3633
const state: AvatarRoot.State = React.useMemo(
3734
() => ({

packages/react/src/collapsible/panel/useCollapsiblePanel.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
55
import { useEventCallback } from '../../utils/useEventCallback';
66
import { useForkRef } from '../../utils/useForkRef';
77
import { useOnMount } from '../../utils/useOnMount';
8+
import { AnimationFrame } from '../../utils/useAnimationFrame';
89
import { warn } from '../../utils/warn';
910
import type { AnimationType, Dimensions } from '../root/useCollapsibleRoot';
1011
import { CollapsiblePanelDataAttributes } from './CollapsiblePanelDataAttributes';
@@ -132,9 +133,9 @@ export function useCollapsiblePanel(
132133
let frame = -1;
133134
let nextFrame = -1;
134135

135-
frame = requestAnimationFrame(() => {
136+
frame = AnimationFrame.request(() => {
136137
shouldCancelInitialOpenTransitionRef.current = false;
137-
nextFrame = requestAnimationFrame(() => {
138+
nextFrame = AnimationFrame.request(() => {
138139
/**
139140
* This is slightly faster than another RAF and is the earliest
140141
* opportunity to remove the temporary `transition-duration: 0s` that
@@ -148,8 +149,8 @@ export function useCollapsiblePanel(
148149
});
149150

150151
return () => {
151-
cancelAnimationFrame(frame);
152-
cancelAnimationFrame(nextFrame);
152+
AnimationFrame.cancel(frame);
153+
AnimationFrame.cancel(nextFrame);
153154
};
154155
});
155156

@@ -195,12 +196,12 @@ export function useCollapsiblePanel(
195196

196197
setDimensions({ height: panel.scrollHeight, width: panel.scrollWidth });
197198

198-
resizeFrame = requestAnimationFrame(() => {
199+
resizeFrame = AnimationFrame.request(() => {
199200
panel.style.removeProperty('display');
200201
});
201202
} else {
202203
/* closing */
203-
resizeFrame = requestAnimationFrame(() => {
204+
resizeFrame = AnimationFrame.request(() => {
204205
setDimensions({ height: 0, width: 0 });
205206
});
206207

@@ -214,7 +215,7 @@ export function useCollapsiblePanel(
214215
}
215216

216217
return () => {
217-
cancelAnimationFrame(resizeFrame);
218+
AnimationFrame.cancel(resizeFrame);
218219
};
219220
}, [
220221
abortControllerRef,
@@ -278,10 +279,10 @@ export function useCollapsiblePanel(
278279
]);
279280

280281
useOnMount(() => {
281-
const frame = requestAnimationFrame(() => {
282+
const frame = AnimationFrame.request(() => {
282283
shouldCancelInitialOpenAnimationRef.current = false;
283284
});
284-
return () => cancelAnimationFrame(frame);
285+
return () => AnimationFrame.cancel(frame);
285286
});
286287

287288
useModernLayoutEffect(() => {
@@ -300,9 +301,9 @@ export function useCollapsiblePanel(
300301
if (open && isBeforeMatchRef.current) {
301302
panel.style.transitionDuration = '0s';
302303
setDimensions({ height: panel.scrollHeight, width: panel.scrollWidth });
303-
frame = requestAnimationFrame(() => {
304+
frame = AnimationFrame.request(() => {
304305
isBeforeMatchRef.current = false;
305-
nextFrame = requestAnimationFrame(() => {
306+
nextFrame = AnimationFrame.request(() => {
306307
setTimeout(() => {
307308
panel.style.removeProperty('transition-duration');
308309
});
@@ -311,8 +312,8 @@ export function useCollapsiblePanel(
311312
}
312313

313314
return () => {
314-
cancelAnimationFrame(frame);
315-
cancelAnimationFrame(nextFrame);
315+
AnimationFrame.cancel(frame);
316+
AnimationFrame.cancel(nextFrame);
316317
};
317318
}, [hiddenUntilFound, open, panelRef, setDimensions]);
318319

packages/react/src/collapsible/root/useCollapsibleRoot.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useBaseUiId } from '../../utils/useBaseUiId';
55
import { useControlled } from '../../utils/useControlled';
66
import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
77
import { useEventCallback } from '../../utils/useEventCallback';
8+
import { AnimationFrame } from '../../utils/useAnimationFrame';
89
import { useTransitionStatus, TransitionStatus } from '../../utils/useTransitionStatus';
910

1011
export type AnimationType = 'css-transition' | 'css-animation' | 'none' | null;
@@ -112,7 +113,7 @@ export function useCollapsibleRoot(
112113
panel.style.removeProperty('content-visibility');
113114
panel.style.setProperty(transitionDimensionRef.current ?? 'height', '0px');
114115

115-
requestAnimationFrame(() => {
116+
AnimationFrame.request(() => {
116117
panel.style.removeProperty(transitionDimensionRef.current ?? 'height');
117118
setDimensions({ height: panel.scrollHeight, width: panel.scrollWidth });
118119
panel.style.removeProperty('display');
@@ -122,7 +123,7 @@ export function useCollapsibleRoot(
122123
panel.style.setProperty('content-visibility', 'visible');
123124
}
124125
/* closing */
125-
requestAnimationFrame(() => {
126+
AnimationFrame.request(() => {
126127
setDimensions({ height: 0, width: 0 });
127128
});
128129

packages/react/src/field/control/useFieldControlValidation.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22
import * as React from 'react';
3+
import { useTimeout } from '../../utils/useTimeout';
34
import { useEventCallback } from '../../utils/useEventCallback';
45
import { useFieldRootContext } from '../root/FieldRootContext';
56
import { mergeProps } from '../../merge-props';
@@ -27,15 +28,9 @@ export function useFieldControlValidation() {
2728

2829
const { formRef, clearErrors } = useFormContext();
2930

30-
const timeoutRef = React.useRef(-1);
31+
const timeout = useTimeout();
3132
const inputRef = React.useRef<HTMLInputElement | null>(null);
3233

33-
React.useEffect(() => {
34-
return () => {
35-
window.clearTimeout(timeoutRef.current);
36-
};
37-
}, []);
38-
3934
const commitValidation = useEventCallback(async (value: unknown, revalidate = false) => {
4035
const element = inputRef.current;
4136
if (!element) {
@@ -78,7 +73,7 @@ export function useFieldControlValidation() {
7873
return computedState;
7974
}
8075

81-
window.clearTimeout(timeoutRef.current);
76+
timeout.clear();
8277

8378
const resultOrPromise = validate(value);
8479
let result: null | string | string[] = null;
@@ -167,12 +162,12 @@ export function useFieldControlValidation() {
167162
return;
168163
}
169164

170-
window.clearTimeout(timeoutRef.current);
165+
timeout.clear();
171166

172167
if (validationDebounceTime) {
173-
timeoutRef.current = window.setTimeout(() => {
168+
timeout.start(validationDebounceTime, () => {
174169
commitValidation(element.value);
175-
}, validationDebounceTime);
170+
});
176171
} else {
177172
commitValidation(element.value);
178173
}
@@ -184,6 +179,7 @@ export function useFieldControlValidation() {
184179
getValidationProps,
185180
clearErrors,
186181
name,
182+
timeout,
187183
commitValidation,
188184
invalid,
189185
validationMode,

packages/react/src/menu/root/useMenuRoot.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '@floating-ui/react';
1515
import { useClick } from '../../utils/floating-ui/useClick';
1616
import { GenericHTMLProps } from '../../utils/types';
17+
import { useTimeout } from '../../utils/useTimeout';
1718
import { useTransitionStatus, type TransitionStatus } from '../../utils/useTransitionStatus';
1819
import { useEventCallback } from '../../utils/useEventCallback';
1920
import { useControlled } from '../../utils/useControlled';
@@ -58,7 +59,7 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
5859

5960
const popupRef = React.useRef<HTMLElement>(null);
6061
const positionerRef = React.useRef<HTMLElement | null>(null);
61-
const stickIfOpenTimeoutRef = React.useRef(-1);
62+
const stickIfOpenTimeout = useTimeout();
6263

6364
const [open, setOpenUnwrapped] = useControlled({
6465
controlled: openParam,
@@ -120,19 +121,11 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
120121
handleUnmount,
121122
]);
122123

123-
const clearStickIfOpenTimeout = useEventCallback(() => {
124-
clearTimeout(stickIfOpenTimeoutRef.current);
125-
});
126-
127124
React.useEffect(() => {
128125
if (!open) {
129-
clearStickIfOpenTimeout();
126+
stickIfOpenTimeout.clear();
130127
}
131-
}, [clearStickIfOpenTimeout, open]);
132-
133-
React.useEffect(() => {
134-
return clearStickIfOpenTimeout;
135-
}, [clearStickIfOpenTimeout]);
128+
}, [stickIfOpenTimeout, open]);
136129

137130
const floatingRootContext = useFloatingRootContext({
138131
elements: {
@@ -152,11 +145,10 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
152145
if (isHover) {
153146
// Only allow "patient" clicks to close the menu if it's open.
154147
// If they clicked within 500ms of the menu opening, keep it open.
155-
clearStickIfOpenTimeout();
156148
setStickIfOpen(true);
157-
stickIfOpenTimeoutRef.current = window.setTimeout(() => {
149+
stickIfOpenTimeout.start(PATIENT_CLICK_THRESHOLD, () => {
158150
setStickIfOpen(false);
159-
}, PATIENT_CLICK_THRESHOLD);
151+
});
160152

161153
ReactDOM.flushSync(changeState);
162154
} else {

packages/react/src/menu/trigger/useMenuTrigger.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { contains } from '@floating-ui/react/utils';
44
import { useButton } from '../../use-button/useButton';
55
import { useForkRef } from '../../utils/useForkRef';
66
import { GenericHTMLProps } from '../../utils/types';
7+
import { useTimeout } from '../../utils/useTimeout';
78
import { mergeProps } from '../../merge-props';
89
import { ownerDocument } from '../../utils/owner';
910
import { getPseudoElementBounds } from '../../utils/getPseudoElementBounds';
@@ -24,7 +25,7 @@ export function useMenuTrigger(parameters: useMenuTrigger.Parameters): useMenuTr
2425

2526
const triggerRef = React.useRef<HTMLElement | null>(null);
2627
const mergedRef = useForkRef(externalRef, triggerRef);
27-
const allowMouseUpTriggerTimeoutRef = React.useRef(-1);
28+
const allowMouseUpTriggerTimeout = useTimeout();
2829

2930
const { getButtonProps, buttonRef } = useButton({
3031
disabled,
@@ -52,9 +53,9 @@ export function useMenuTrigger(parameters: useMenuTrigger.Parameters): useMenuTr
5253
}
5354

5455
// mousedown -> mouseup on menu item should not trigger it within 200ms.
55-
allowMouseUpTriggerTimeoutRef.current = window.setTimeout(() => {
56+
allowMouseUpTriggerTimeout.start(200, () => {
5657
allowMouseUpTriggerRef.current = true;
57-
}, 200);
58+
});
5859

5960
const doc = ownerDocument(event.currentTarget);
6061

@@ -63,10 +64,7 @@ export function useMenuTrigger(parameters: useMenuTrigger.Parameters): useMenuTr
6364
return;
6465
}
6566

66-
if (allowMouseUpTriggerTimeoutRef.current !== -1) {
67-
clearTimeout(allowMouseUpTriggerTimeoutRef.current);
68-
allowMouseUpTriggerTimeoutRef.current = -1;
69-
}
67+
allowMouseUpTriggerTimeout.clear();
7068
allowMouseUpTriggerRef.current = false;
7169

7270
const mouseUpTarget = mouseEvent.target as Element | null;
@@ -100,7 +98,15 @@ export function useMenuTrigger(parameters: useMenuTrigger.Parameters): useMenuTr
10098
getButtonProps,
10199
);
102100
},
103-
[getButtonProps, handleRef, open, setOpen, positionerRef, allowMouseUpTriggerRef],
101+
[
102+
getButtonProps,
103+
handleRef,
104+
open,
105+
setOpen,
106+
positionerRef,
107+
allowMouseUpTriggerRef,
108+
allowMouseUpTriggerTimeout,
109+
],
104110
);
105111

106112
return React.useMemo(

packages/react/src/number-field/decrement/NumberFieldDecrement.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const NumberFieldDecrement = React.forwardRef(function NumberFieldDecreme
2828
incrementValue,
2929
inputRef,
3030
inputValue,
31-
intentionalTouchCheckTimeoutRef,
31+
intentionalTouchCheckTimeout,
3232
isPressedRef,
3333
maxWithDefault,
3434
minWithDefault,
@@ -61,7 +61,7 @@ export const NumberFieldDecrement = React.forwardRef(function NumberFieldDecreme
6161
formatOptionsRef,
6262
valueRef,
6363
isPressedRef,
64-
intentionalTouchCheckTimeoutRef,
64+
intentionalTouchCheckTimeout,
6565
movesAfterTouchRef,
6666
locale,
6767
});

packages/react/src/number-field/increment/NumberFieldIncrement.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const NumberFieldIncrement = React.forwardRef(function NumberFieldIncreme
3838
formatOptionsRef,
3939
valueRef,
4040
isPressedRef,
41-
intentionalTouchCheckTimeoutRef,
41+
intentionalTouchCheckTimeout,
4242
movesAfterTouchRef,
4343
locale,
4444
} = useNumberFieldRootContext();
@@ -61,7 +61,7 @@ export const NumberFieldIncrement = React.forwardRef(function NumberFieldIncreme
6161
formatOptionsRef,
6262
valueRef,
6363
isPressedRef,
64-
intentionalTouchCheckTimeoutRef,
64+
intentionalTouchCheckTimeout,
6565
movesAfterTouchRef,
6666
locale,
6767
});

0 commit comments

Comments
 (0)