preact-missing-hooks 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/pre-commit +1 -0
- package/.husky/pre-push +1 -0
- package/.prettierignore +3 -0
- package/.prettierrc +6 -0
- package/Readme.md +179 -131
- package/dist/entry.cjs +21 -0
- package/dist/entry.js +2 -0
- package/dist/entry.js.map +1 -0
- package/dist/entry.modern.mjs +2 -0
- package/dist/entry.modern.mjs.map +1 -0
- package/dist/entry.module.js +2 -0
- package/dist/entry.module.js.map +1 -0
- package/dist/entry.umd.js +2 -0
- package/dist/entry.umd.js.map +1 -0
- package/dist/index.d.ts +13 -12
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.modern.mjs +2 -0
- package/dist/index.modern.mjs.map +1 -0
- package/dist/index.module.js +1 -1
- package/dist/index.module.js.map +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/index.umd.js.map +1 -1
- package/dist/indexedDB/dbController.d.ts +2 -2
- package/dist/indexedDB/index.d.ts +6 -6
- package/dist/indexedDB/openDB.d.ts +1 -1
- package/dist/indexedDB/tableController.d.ts +1 -1
- package/dist/indexedDB/types.d.ts +1 -2
- package/dist/react.js +1 -0
- package/dist/react.modern.mjs +1 -0
- package/dist/react.module.js +1 -0
- package/dist/react.umd.js +1 -0
- package/dist/useEventBus.d.ts +1 -1
- package/dist/useIndexedDB.d.ts +3 -3
- package/dist/useMutationObserver.d.ts +1 -1
- package/dist/useNetworkState.d.ts +3 -3
- package/dist/usePreferredTheme.d.ts +1 -1
- package/dist/useRageClick.d.ts +1 -1
- package/dist/useThreadedWorker.d.ts +1 -1
- package/dist/useTransition.d.ts +4 -1
- package/dist/useWorkerNotifications.d.ts +57 -0
- package/dist/useWrappedChildren.d.ts +3 -3
- package/{demo → docs}/index.html +56 -0
- package/{demo → docs}/main.js +437 -312
- package/eslint.config.mjs +10 -0
- package/package.json +65 -6
- package/scripts/generate-entry.cjs +34 -0
- package/src/index.ts +13 -12
- package/src/indexedDB/dbController.ts +101 -92
- package/src/indexedDB/index.ts +16 -11
- package/src/indexedDB/openDB.ts +49 -49
- package/src/indexedDB/requestToPromise.ts +17 -16
- package/src/indexedDB/tableController.ts +331 -257
- package/src/indexedDB/types.ts +35 -35
- package/src/useClipboard.ts +99 -97
- package/src/useEventBus.ts +39 -36
- package/src/useIndexedDB.ts +111 -111
- package/src/useMutationObserver.ts +26 -26
- package/src/useNetworkState.ts +124 -122
- package/src/usePreferredTheme.ts +68 -68
- package/src/useRageClick.ts +103 -103
- package/src/useThreadedWorker.ts +165 -165
- package/src/useTransition.ts +22 -19
- package/src/useWasmCompute.ts +209 -204
- package/src/useWebRTCIP.ts +181 -176
- package/src/useWorkerNotifications.ts +203 -0
- package/src/useWrappedChildren.ts +72 -58
- package/tests/react-adapter.tsx +12 -0
- package/tests/setup-react.ts +4 -0
- package/tests/useClipboard.test.tsx +4 -2
- package/tests/useThreadedWorker.test.tsx +3 -1
- package/tests/useWasmCompute.test.tsx +1 -1
- package/tests/useWebRTCIP.test.tsx +3 -1
- package/tests/useWorkerNotifications.test.tsx +170 -0
- package/vite.config.ts +11 -4
- package/vitest.config.preact.ts +20 -0
- package/vitest.config.react.ts +36 -0
- package/vitest.workspace.ts +6 -0
- /package/{demo → docs}/add.wasm +0 -0
package/src/useNetworkState.ts
CHANGED
|
@@ -1,122 +1,124 @@
|
|
|
1
|
-
import { useEffect, useState } from
|
|
2
|
-
|
|
3
|
-
/** Network Information API (not in all browsers) */
|
|
4
|
-
interface NetworkInformation extends EventTarget {
|
|
5
|
-
effectiveType?: string;
|
|
6
|
-
downlink?: number;
|
|
7
|
-
rtt?: number;
|
|
8
|
-
saveData?: boolean;
|
|
9
|
-
type?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/** Effective connection type from Network Information API */
|
|
13
|
-
export type EffectiveConnectionType =
|
|
14
|
-
|
|
15
|
-
/** Network connection type (e.g., wifi, cellular) */
|
|
16
|
-
export type ConnectionType =
|
|
17
|
-
|
|
|
18
|
-
|
|
|
19
|
-
|
|
|
20
|
-
|
|
|
21
|
-
|
|
|
22
|
-
|
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
|
|
26
|
-
export interface NetworkState {
|
|
27
|
-
/** Whether the browser is online */
|
|
28
|
-
online: boolean;
|
|
29
|
-
/** Effective connection type (when supported) */
|
|
30
|
-
effectiveType?: EffectiveConnectionType;
|
|
31
|
-
/** Estimated downlink speed in Mbps (when supported) */
|
|
32
|
-
downlink?: number;
|
|
33
|
-
/** Estimated round-trip time in ms (when supported) */
|
|
34
|
-
rtt?: number;
|
|
35
|
-
/** Whether the user has requested reduced data usage (when supported) */
|
|
36
|
-
saveData?: boolean;
|
|
37
|
-
/** Connection type (when supported) */
|
|
38
|
-
connectionType?: ConnectionType;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function getNetworkState(): NetworkState {
|
|
42
|
-
if (typeof navigator ===
|
|
43
|
-
return { online: true };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const state: NetworkState = {
|
|
47
|
-
online: navigator.onLine,
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const connection =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* {
|
|
89
|
-
* {
|
|
90
|
-
*
|
|
91
|
-
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
window.addEventListener(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
1
|
+
import { useEffect, useState } from "preact/hooks";
|
|
2
|
+
|
|
3
|
+
/** Network Information API (not in all browsers) */
|
|
4
|
+
interface NetworkInformation extends EventTarget {
|
|
5
|
+
effectiveType?: string;
|
|
6
|
+
downlink?: number;
|
|
7
|
+
rtt?: number;
|
|
8
|
+
saveData?: boolean;
|
|
9
|
+
type?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Effective connection type from Network Information API */
|
|
13
|
+
export type EffectiveConnectionType = "slow-2g" | "2g" | "3g" | "4g";
|
|
14
|
+
|
|
15
|
+
/** Network connection type (e.g., wifi, cellular) */
|
|
16
|
+
export type ConnectionType =
|
|
17
|
+
| "bluetooth"
|
|
18
|
+
| "cellular"
|
|
19
|
+
| "ethernet"
|
|
20
|
+
| "mixed"
|
|
21
|
+
| "none"
|
|
22
|
+
| "other"
|
|
23
|
+
| "unknown"
|
|
24
|
+
| "wifi";
|
|
25
|
+
|
|
26
|
+
export interface NetworkState {
|
|
27
|
+
/** Whether the browser is online */
|
|
28
|
+
online: boolean;
|
|
29
|
+
/** Effective connection type (when supported) */
|
|
30
|
+
effectiveType?: EffectiveConnectionType;
|
|
31
|
+
/** Estimated downlink speed in Mbps (when supported) */
|
|
32
|
+
downlink?: number;
|
|
33
|
+
/** Estimated round-trip time in ms (when supported) */
|
|
34
|
+
rtt?: number;
|
|
35
|
+
/** Whether the user has requested reduced data usage (when supported) */
|
|
36
|
+
saveData?: boolean;
|
|
37
|
+
/** Connection type (when supported) */
|
|
38
|
+
connectionType?: ConnectionType;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getNetworkState(): NetworkState {
|
|
42
|
+
if (typeof navigator === "undefined") {
|
|
43
|
+
return { online: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const state: NetworkState = {
|
|
47
|
+
online: navigator.onLine,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const connection = (
|
|
51
|
+
navigator as Navigator & { connection?: NetworkInformation }
|
|
52
|
+
).connection;
|
|
53
|
+
|
|
54
|
+
if (connection) {
|
|
55
|
+
if (connection.effectiveType !== undefined) {
|
|
56
|
+
state.effectiveType = connection.effectiveType as EffectiveConnectionType;
|
|
57
|
+
}
|
|
58
|
+
if (connection.downlink !== undefined) {
|
|
59
|
+
state.downlink = connection.downlink;
|
|
60
|
+
}
|
|
61
|
+
if (connection.rtt !== undefined) {
|
|
62
|
+
state.rtt = connection.rtt;
|
|
63
|
+
}
|
|
64
|
+
if (connection.saveData !== undefined) {
|
|
65
|
+
state.saveData = connection.saveData;
|
|
66
|
+
}
|
|
67
|
+
if (connection.type !== undefined) {
|
|
68
|
+
state.connectionType = connection.type as ConnectionType;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return state;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A Preact hook that returns the current network state, including online/offline
|
|
77
|
+
* status and (when supported) connection type, downlink, RTT, and save-data preference.
|
|
78
|
+
* Updates reactively when the network state changes.
|
|
79
|
+
*
|
|
80
|
+
* @returns The current network state object
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```tsx
|
|
84
|
+
* function NetworkStatus() {
|
|
85
|
+
* const { online, effectiveType, saveData } = useNetworkState();
|
|
86
|
+
* return (
|
|
87
|
+
* <div>
|
|
88
|
+
* Status: {online ? 'Online' : 'Offline'}
|
|
89
|
+
* {effectiveType && ` (${effectiveType})`}
|
|
90
|
+
* {saveData && ' - Reduced data mode'}
|
|
91
|
+
* </div>
|
|
92
|
+
* );
|
|
93
|
+
* }
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export function useNetworkState(): NetworkState {
|
|
97
|
+
const [state, setState] = useState<NetworkState>(getNetworkState);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (typeof window === "undefined") return;
|
|
101
|
+
|
|
102
|
+
const updateState = () => setState(getNetworkState());
|
|
103
|
+
|
|
104
|
+
window.addEventListener("online", updateState);
|
|
105
|
+
window.addEventListener("offline", updateState);
|
|
106
|
+
|
|
107
|
+
const connection = (
|
|
108
|
+
navigator as Navigator & { connection?: NetworkInformation }
|
|
109
|
+
).connection;
|
|
110
|
+
if (connection?.addEventListener) {
|
|
111
|
+
connection.addEventListener("change", updateState);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return () => {
|
|
115
|
+
window.removeEventListener("online", updateState);
|
|
116
|
+
window.removeEventListener("offline", updateState);
|
|
117
|
+
if (connection?.removeEventListener) {
|
|
118
|
+
connection.removeEventListener("change", updateState);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
return state;
|
|
124
|
+
}
|
package/src/usePreferredTheme.ts
CHANGED
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
import { useEffect, useState } from
|
|
2
|
-
|
|
3
|
-
export type PreferredTheme =
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* A Preact hook that returns the user's preferred color scheme based on the
|
|
7
|
-
* `prefers-color-scheme` media query. Updates reactively when the user changes
|
|
8
|
-
* their system or browser theme preference.
|
|
9
|
-
*
|
|
10
|
-
* @returns The preferred theme: 'light', 'dark', or 'no-preference'
|
|
11
|
-
*
|
|
12
|
-
* @example
|
|
13
|
-
* ```tsx
|
|
14
|
-
* function ThemeAwareComponent() {
|
|
15
|
-
* const theme = usePreferredTheme();
|
|
16
|
-
* return (
|
|
17
|
-
* <div data-theme={theme}>
|
|
18
|
-
* Current preference: {theme}
|
|
19
|
-
* </div>
|
|
20
|
-
* );
|
|
21
|
-
* }
|
|
22
|
-
* ```
|
|
23
|
-
*/
|
|
24
|
-
export function usePreferredTheme(): PreferredTheme {
|
|
25
|
-
const [theme, setTheme] = useState<PreferredTheme>(() => {
|
|
26
|
-
if (typeof window ===
|
|
27
|
-
|
|
28
|
-
const darkQuery = window.matchMedia(
|
|
29
|
-
const lightQuery = window.matchMedia(
|
|
30
|
-
|
|
31
|
-
if (darkQuery.matches) return
|
|
32
|
-
if (lightQuery.matches) return
|
|
33
|
-
return
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
useEffect(() => {
|
|
37
|
-
if (typeof window ===
|
|
38
|
-
|
|
39
|
-
const mediaQuery = window.matchMedia(
|
|
40
|
-
|
|
41
|
-
const handleChange = (e: MediaQueryListEvent) => {
|
|
42
|
-
setTheme(e.matches ?
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
// Re-check in case of no-preference (some browsers don't support light query)
|
|
46
|
-
const updateTheme = () => {
|
|
47
|
-
const darkQuery = window.matchMedia(
|
|
48
|
-
const lightQuery = window.matchMedia(
|
|
49
|
-
|
|
50
|
-
if (darkQuery.matches) setTheme(
|
|
51
|
-
else if (lightQuery.matches) setTheme(
|
|
52
|
-
else setTheme(
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
mediaQuery.addEventListener(
|
|
56
|
-
|
|
57
|
-
// Fallback: some environments may not fire change, so we also listen for light
|
|
58
|
-
const lightQuery = window.matchMedia(
|
|
59
|
-
lightQuery.addEventListener(
|
|
60
|
-
|
|
61
|
-
return () => {
|
|
62
|
-
mediaQuery.removeEventListener(
|
|
63
|
-
lightQuery.removeEventListener(
|
|
64
|
-
};
|
|
65
|
-
}, []);
|
|
66
|
-
|
|
67
|
-
return theme;
|
|
68
|
-
}
|
|
1
|
+
import { useEffect, useState } from "preact/hooks";
|
|
2
|
+
|
|
3
|
+
export type PreferredTheme = "light" | "dark" | "no-preference";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A Preact hook that returns the user's preferred color scheme based on the
|
|
7
|
+
* `prefers-color-scheme` media query. Updates reactively when the user changes
|
|
8
|
+
* their system or browser theme preference.
|
|
9
|
+
*
|
|
10
|
+
* @returns The preferred theme: 'light', 'dark', or 'no-preference'
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* function ThemeAwareComponent() {
|
|
15
|
+
* const theme = usePreferredTheme();
|
|
16
|
+
* return (
|
|
17
|
+
* <div data-theme={theme}>
|
|
18
|
+
* Current preference: {theme}
|
|
19
|
+
* </div>
|
|
20
|
+
* );
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function usePreferredTheme(): PreferredTheme {
|
|
25
|
+
const [theme, setTheme] = useState<PreferredTheme>(() => {
|
|
26
|
+
if (typeof window === "undefined") return "no-preference";
|
|
27
|
+
|
|
28
|
+
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
29
|
+
const lightQuery = window.matchMedia("(prefers-color-scheme: light)");
|
|
30
|
+
|
|
31
|
+
if (darkQuery.matches) return "dark";
|
|
32
|
+
if (lightQuery.matches) return "light";
|
|
33
|
+
return "no-preference";
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (typeof window === "undefined") return;
|
|
38
|
+
|
|
39
|
+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
40
|
+
|
|
41
|
+
const handleChange = (e: MediaQueryListEvent) => {
|
|
42
|
+
setTheme(e.matches ? "dark" : "light");
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Re-check in case of no-preference (some browsers don't support light query)
|
|
46
|
+
const updateTheme = () => {
|
|
47
|
+
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
48
|
+
const lightQuery = window.matchMedia("(prefers-color-scheme: light)");
|
|
49
|
+
|
|
50
|
+
if (darkQuery.matches) setTheme("dark");
|
|
51
|
+
else if (lightQuery.matches) setTheme("light");
|
|
52
|
+
else setTheme("no-preference");
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
mediaQuery.addEventListener("change", handleChange);
|
|
56
|
+
|
|
57
|
+
// Fallback: some environments may not fire change, so we also listen for light
|
|
58
|
+
const lightQuery = window.matchMedia("(prefers-color-scheme: light)");
|
|
59
|
+
lightQuery.addEventListener("change", updateTheme);
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
mediaQuery.removeEventListener("change", handleChange);
|
|
63
|
+
lightQuery.removeEventListener("change", updateTheme);
|
|
64
|
+
};
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
return theme;
|
|
68
|
+
}
|
package/src/useRageClick.ts
CHANGED
|
@@ -1,103 +1,103 @@
|
|
|
1
|
-
import type { RefObject } from
|
|
2
|
-
import { useEffect, useRef } from
|
|
3
|
-
|
|
4
|
-
export interface RageClickPayload {
|
|
5
|
-
/** Number of clicks that triggered the rage click */
|
|
6
|
-
count: number
|
|
7
|
-
/** Last click event (e.g. for Sentry context) */
|
|
8
|
-
event: MouseEvent
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface UseRageClickOptions {
|
|
12
|
-
/** Called when a rage click is detected. Use this to report to Sentry or your error tracker. */
|
|
13
|
-
onRageClick: (payload: RageClickPayload) => void
|
|
14
|
-
/** Minimum number of clicks in the time window to count as rage click. Default: 5 (Sentry-style). */
|
|
15
|
-
threshold?: number
|
|
16
|
-
/** Time window in ms. Default: 1000. */
|
|
17
|
-
timeWindow?: number
|
|
18
|
-
/** Max distance in px between clicks to count as same spot. Default: 30. Set to Infinity to ignore distance. */
|
|
19
|
-
distanceThreshold?: number
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface ClickRecord {
|
|
23
|
-
time: number
|
|
24
|
-
x: number
|
|
25
|
-
y: number
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function distance(a: ClickRecord, b: ClickRecord): number {
|
|
29
|
-
return Math.hypot(b.x - a.x, b.y - a.y)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Detects "rage clicks" (repeated rapid clicks in the same area), e.g. when the UI
|
|
34
|
-
* is unresponsive. Use the callback to report to Sentry or similar tools to surface
|
|
35
|
-
* rage click issues and lower rage-click-related support.
|
|
36
|
-
*
|
|
37
|
-
* @param targetRef - Ref of the element to monitor (e.g. a button or card).
|
|
38
|
-
* @param options - onRageClick callback and optional threshold, timeWindow, distanceThreshold.
|
|
39
|
-
*
|
|
40
|
-
* @example
|
|
41
|
-
* ```tsx
|
|
42
|
-
* const ref = useRef<HTMLButtonElement>(null)
|
|
43
|
-
* useRageClick(ref, {
|
|
44
|
-
* onRageClick: ({ count, event }) => {
|
|
45
|
-
* Sentry.captureMessage('Rage click detected', { extra: { count, target: event.target } })
|
|
46
|
-
* },
|
|
47
|
-
* })
|
|
48
|
-
* return <button ref={ref}>Submit</button>
|
|
49
|
-
* ```
|
|
50
|
-
*/
|
|
51
|
-
export function useRageClick(
|
|
52
|
-
targetRef: RefObject<HTMLElement | null>,
|
|
53
|
-
options: UseRageClickOptions
|
|
54
|
-
) {
|
|
55
|
-
const {
|
|
56
|
-
onRageClick,
|
|
57
|
-
threshold = 5,
|
|
58
|
-
timeWindow = 1000,
|
|
59
|
-
distanceThreshold = 30,
|
|
60
|
-
} = options
|
|
61
|
-
|
|
62
|
-
const onRageClickRef = useRef(onRageClick)
|
|
63
|
-
onRageClickRef.current = onRageClick
|
|
64
|
-
|
|
65
|
-
const clicksRef = useRef<ClickRecord[]>([])
|
|
66
|
-
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
const node = targetRef.current
|
|
69
|
-
if (!node) return
|
|
70
|
-
|
|
71
|
-
const handleClick = (e: MouseEvent) => {
|
|
72
|
-
const now = Date.now()
|
|
73
|
-
const record: ClickRecord = { time: now, x: e.clientX, y: e.clientY }
|
|
74
|
-
|
|
75
|
-
const clicks = clicksRef.current
|
|
76
|
-
const cutoff = now - timeWindow
|
|
77
|
-
const recent = clicks.filter((c) => c.time >= cutoff)
|
|
78
|
-
recent.push(record)
|
|
79
|
-
|
|
80
|
-
if (distanceThreshold !== Infinity) {
|
|
81
|
-
const inRange = recent.filter(
|
|
82
|
-
(c) => distance(c, record) <= distanceThreshold
|
|
83
|
-
)
|
|
84
|
-
if (inRange.length >= threshold) {
|
|
85
|
-
onRageClickRef.current({ count: inRange.length, event: e })
|
|
86
|
-
clicksRef.current = []
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
|
-
} else {
|
|
90
|
-
if (recent.length >= threshold) {
|
|
91
|
-
onRageClickRef.current({ count: recent.length, event: e })
|
|
92
|
-
clicksRef.current = []
|
|
93
|
-
return
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
clicksRef.current = recent
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
node.addEventListener(
|
|
101
|
-
return () => node.removeEventListener(
|
|
102
|
-
}, [targetRef, threshold, timeWindow, distanceThreshold])
|
|
103
|
-
}
|
|
1
|
+
import type { RefObject } from "preact";
|
|
2
|
+
import { useEffect, useRef } from "preact/hooks";
|
|
3
|
+
|
|
4
|
+
export interface RageClickPayload {
|
|
5
|
+
/** Number of clicks that triggered the rage click */
|
|
6
|
+
count: number;
|
|
7
|
+
/** Last click event (e.g. for Sentry context) */
|
|
8
|
+
event: MouseEvent;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UseRageClickOptions {
|
|
12
|
+
/** Called when a rage click is detected. Use this to report to Sentry or your error tracker. */
|
|
13
|
+
onRageClick: (payload: RageClickPayload) => void;
|
|
14
|
+
/** Minimum number of clicks in the time window to count as rage click. Default: 5 (Sentry-style). */
|
|
15
|
+
threshold?: number;
|
|
16
|
+
/** Time window in ms. Default: 1000. */
|
|
17
|
+
timeWindow?: number;
|
|
18
|
+
/** Max distance in px between clicks to count as same spot. Default: 30. Set to Infinity to ignore distance. */
|
|
19
|
+
distanceThreshold?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ClickRecord {
|
|
23
|
+
time: number;
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function distance(a: ClickRecord, b: ClickRecord): number {
|
|
29
|
+
return Math.hypot(b.x - a.x, b.y - a.y);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Detects "rage clicks" (repeated rapid clicks in the same area), e.g. when the UI
|
|
34
|
+
* is unresponsive. Use the callback to report to Sentry or similar tools to surface
|
|
35
|
+
* rage click issues and lower rage-click-related support.
|
|
36
|
+
*
|
|
37
|
+
* @param targetRef - Ref of the element to monitor (e.g. a button or card).
|
|
38
|
+
* @param options - onRageClick callback and optional threshold, timeWindow, distanceThreshold.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```tsx
|
|
42
|
+
* const ref = useRef<HTMLButtonElement>(null)
|
|
43
|
+
* useRageClick(ref, {
|
|
44
|
+
* onRageClick: ({ count, event }) => {
|
|
45
|
+
* Sentry.captureMessage('Rage click detected', { extra: { count, target: event.target } })
|
|
46
|
+
* },
|
|
47
|
+
* })
|
|
48
|
+
* return <button ref={ref}>Submit</button>
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function useRageClick(
|
|
52
|
+
targetRef: RefObject<HTMLElement | null>,
|
|
53
|
+
options: UseRageClickOptions
|
|
54
|
+
) {
|
|
55
|
+
const {
|
|
56
|
+
onRageClick,
|
|
57
|
+
threshold = 5,
|
|
58
|
+
timeWindow = 1000,
|
|
59
|
+
distanceThreshold = 30,
|
|
60
|
+
} = options;
|
|
61
|
+
|
|
62
|
+
const onRageClickRef = useRef(onRageClick);
|
|
63
|
+
onRageClickRef.current = onRageClick;
|
|
64
|
+
|
|
65
|
+
const clicksRef = useRef<ClickRecord[]>([]);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const node = targetRef.current;
|
|
69
|
+
if (!node) return;
|
|
70
|
+
|
|
71
|
+
const handleClick = (e: MouseEvent) => {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const record: ClickRecord = { time: now, x: e.clientX, y: e.clientY };
|
|
74
|
+
|
|
75
|
+
const clicks = clicksRef.current;
|
|
76
|
+
const cutoff = now - timeWindow;
|
|
77
|
+
const recent = clicks.filter((c) => c.time >= cutoff);
|
|
78
|
+
recent.push(record);
|
|
79
|
+
|
|
80
|
+
if (distanceThreshold !== Infinity) {
|
|
81
|
+
const inRange = recent.filter(
|
|
82
|
+
(c) => distance(c, record) <= distanceThreshold
|
|
83
|
+
);
|
|
84
|
+
if (inRange.length >= threshold) {
|
|
85
|
+
onRageClickRef.current({ count: inRange.length, event: e });
|
|
86
|
+
clicksRef.current = [];
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
if (recent.length >= threshold) {
|
|
91
|
+
onRageClickRef.current({ count: recent.length, event: e });
|
|
92
|
+
clicksRef.current = [];
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
clicksRef.current = recent;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
node.addEventListener("click", handleClick);
|
|
101
|
+
return () => node.removeEventListener("click", handleClick);
|
|
102
|
+
}, [targetRef, threshold, timeWindow, distanceThreshold]);
|
|
103
|
+
}
|