preact-missing-hooks 2.0.0 → 3.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/Readme.md +83 -2
- package/demo/add.wasm +0 -0
- package/demo/index.html +242 -0
- package/demo/main.js +312 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- 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/useWasmCompute.d.ts +39 -0
- package/dist/useWebRTCIP.d.ts +37 -0
- package/package.json +124 -113
- package/src/index.ts +12 -10
- package/src/useWasmCompute.ts +204 -0
- package/src/useWebRTCIP.ts +176 -0
- package/tests/useWasmCompute.test.tsx +240 -0
- package/tests/useWebRTCIP.test.tsx +167 -0
package/Readme.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
If this package helps you, please consider dropping a star on the [GitHub repo](https://github.com/prakhardubey2002/Preact-Missing-Hooks).
|
|
17
17
|
|
|
18
|
-
A lightweight, extendable collection of React-like hooks for Preact, including utilities for transitions, DOM mutation observation, global event buses, theme detection, network status, clipboard access, rage-click detection (e.g. for Sentry), a priority task queue (sequential or parallel),
|
|
18
|
+
A lightweight, extendable collection of React-like hooks for Preact, including utilities for transitions, DOM mutation observation, global event buses, theme detection, network status, clipboard access, rage-click detection (e.g. for Sentry), a priority task queue (sequential or parallel), a production-ready **IndexedDB** hook with tables, transactions, and a full CRUD API, and **WebRTC-based IP detection** (`useWebRTCIP`) for frontend-only IP hints.
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
@@ -31,6 +31,8 @@ A lightweight, extendable collection of React-like hooks for Preact, including u
|
|
|
31
31
|
- **`useRageClick`** — Detects rage clicks (repeated rapid clicks in the same spot). Use with Sentry or similar to detect and fix rage-click issues and lower rage-click-related support.
|
|
32
32
|
- **`useThreadedWorker`** — Run async work in a queue with **sequential** (single worker, priority-ordered) or **parallel** (worker pool) mode. Optional priority (1 = highest); FIFO within same priority.
|
|
33
33
|
- **`useIndexedDB`** — IndexedDB abstraction with database/table init, insert, update, delete, exists, query (cursor + filter), upsert, bulk insert, clear, count, and full transaction support. Singleton connection, Promise-based API, optional `onSuccess`/`onError` callbacks.
|
|
34
|
+
- **`useWebRTCIP`** — Detects client IP addresses using WebRTC ICE candidates and a STUN server (frontend-only). **Not highly reliable**; use as a first-priority hint and fall back to a public IP API (e.g. [ipapi.co](https://ipapi.co), [ipify](https://www.ipify.org), [ip-api.com](https://ip-api.com)) when it fails or returns empty.
|
|
35
|
+
- **`useWasmCompute`** — Runs WebAssembly computation off the main thread via a Web Worker. Flow: Preact Component → useWasmCompute() → Web Worker → WASM Module → return result. Validates environment (browser, Worker, WebAssembly) and returns `compute(input)`, `result`, `loading`, `error`, `ready`.
|
|
34
36
|
- Fully TypeScript compatible
|
|
35
37
|
- Bundled with Microbundle
|
|
36
38
|
- Zero dependencies (except `preact`)
|
|
@@ -55,8 +57,10 @@ npm install preact-missing-hooks
|
|
|
55
57
|
```ts
|
|
56
58
|
import { useThreadedWorker } from 'preact-missing-hooks/useThreadedWorker'
|
|
57
59
|
import { useClipboard } from 'preact-missing-hooks/useClipboard'
|
|
60
|
+
import { useWebRTCIP } from 'preact-missing-hooks/useWebRTCIP'
|
|
61
|
+
import { useWasmCompute } from 'preact-missing-hooks/useWasmCompute'
|
|
58
62
|
```
|
|
59
|
-
All hooks are available: `useTransition`, `useMutationObserver`, `useEventBus`, `useWrappedChildren`, `usePreferredTheme`, `useNetworkState`, `useClipboard`, `useRageClick`, `useThreadedWorker`, `useIndexedDB`.
|
|
63
|
+
All hooks are available: `useTransition`, `useMutationObserver`, `useEventBus`, `useWrappedChildren`, `usePreferredTheme`, `useNetworkState`, `useClipboard`, `useRageClick`, `useThreadedWorker`, `useIndexedDB`, `useWebRTCIP`, `useWasmCompute`.
|
|
60
64
|
|
|
61
65
|
---
|
|
62
66
|
|
|
@@ -373,6 +377,83 @@ function App() {
|
|
|
373
377
|
|
|
374
378
|
---
|
|
375
379
|
|
|
380
|
+
### `useWebRTCIP`
|
|
381
|
+
|
|
382
|
+
Detects client IP addresses using WebRTC ICE candidates and a STUN server (**frontend-only**, no backend). **Not highly reliable** — use as a **first-priority** hint; if it fails or returns empty, fall back to a public IP API (e.g. [ipapi.co](https://ipapi.co), [ipify](https://www.ipify.org), [ip-api.com](https://ip-api.com)).
|
|
383
|
+
|
|
384
|
+
Returns `{ ips: string[], loading: boolean, error: string | null }`. Options: `stunServers`, `timeout` (ms), `onDetect(ip)`.
|
|
385
|
+
|
|
386
|
+
```tsx
|
|
387
|
+
import { useWebRTCIP } from 'preact-missing-hooks'
|
|
388
|
+
import { useState, useEffect } from 'preact/hooks'
|
|
389
|
+
|
|
390
|
+
function ClientIP() {
|
|
391
|
+
const { ips, loading, error } = useWebRTCIP({
|
|
392
|
+
timeout: 4000,
|
|
393
|
+
onDetect: (ip) => {
|
|
394
|
+
/* optional: e.g. analytics */
|
|
395
|
+
},
|
|
396
|
+
})
|
|
397
|
+
const [fallbackIP, setFallbackIP] = useState<string | null>(null)
|
|
398
|
+
|
|
399
|
+
// Fallback to public IP API when WebRTC fails or returns empty
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
if (loading || ips.length > 0) return
|
|
402
|
+
if (error) {
|
|
403
|
+
fetch('https://api.ipify.org?format=json')
|
|
404
|
+
.then((r) => r.json())
|
|
405
|
+
.then((d) => setFallbackIP(d.ip))
|
|
406
|
+
.catch(() => {})
|
|
407
|
+
}
|
|
408
|
+
}, [loading, ips.length, error])
|
|
409
|
+
|
|
410
|
+
if (loading) return <p>Detecting IP…</p>
|
|
411
|
+
if (ips.length > 0) return <p>IPs (WebRTC): {ips.join(', ')}</p>
|
|
412
|
+
if (fallbackIP) return <p>IP (fallback API): {fallbackIP}</p>
|
|
413
|
+
if (error) return <p>WebRTC failed. Try fallback API.</p>
|
|
414
|
+
return null
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
### `useWasmCompute`
|
|
421
|
+
|
|
422
|
+
Runs WebAssembly computation in a Web Worker so the main thread stays responsive. Flow: **Preact Component → useWasmCompute() → Web Worker → WASM Module → return result.** The hook checks that the environment supports `window`, `Worker`, and `WebAssembly`; in SSR or unsupported environments it sets `error` and leaves `ready` false.
|
|
423
|
+
|
|
424
|
+
Returns `{ compute, result, loading, error, ready }`. Options: `wasmUrl` (required), `exportName` (default `'compute'`), optional `workerUrl` (custom worker script), optional `importObject` (must be serializable for the default worker).
|
|
425
|
+
|
|
426
|
+
```tsx
|
|
427
|
+
import { useWasmCompute } from 'preact-missing-hooks'
|
|
428
|
+
|
|
429
|
+
function AddWithWasm() {
|
|
430
|
+
const { compute, result, loading, error, ready } = useWasmCompute<
|
|
431
|
+
number,
|
|
432
|
+
number
|
|
433
|
+
>({
|
|
434
|
+
wasmUrl: '/add.wasm',
|
|
435
|
+
exportName: 'add',
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
const handleClick = () => {
|
|
439
|
+
if (ready) compute(2).then(() => {})
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (error) return <p>WASM unavailable: {error}</p>
|
|
443
|
+
if (!ready) return <p>Loading WASM…</p>
|
|
444
|
+
return (
|
|
445
|
+
<div>
|
|
446
|
+
<button onClick={handleClick} disabled={loading}>
|
|
447
|
+
Add 2
|
|
448
|
+
</button>
|
|
449
|
+
{result != null && <p>Result: {result}</p>}
|
|
450
|
+
</div>
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
376
457
|
## Built With
|
|
377
458
|
|
|
378
459
|
- [Preact](https://preactjs.com)
|
package/demo/add.wasm
ADDED
|
Binary file
|
package/demo/index.html
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Preact Missing Hooks — Demo</title>
|
|
7
|
+
<script type="importmap">
|
|
8
|
+
{
|
|
9
|
+
"imports": {
|
|
10
|
+
"preact": "https://esm.sh/preact@10",
|
|
11
|
+
"preact/hooks": "https://esm.sh/preact@10/hooks"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
</script>
|
|
15
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
16
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
17
|
+
<link
|
|
18
|
+
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap"
|
|
19
|
+
rel="stylesheet"
|
|
20
|
+
/>
|
|
21
|
+
<style>
|
|
22
|
+
:root {
|
|
23
|
+
--bg: #0f0f12;
|
|
24
|
+
--surface: #18181c;
|
|
25
|
+
--surface2: #222228;
|
|
26
|
+
--border: #2e2e36;
|
|
27
|
+
--text: #e4e4e7;
|
|
28
|
+
--textMuted: #a1a1aa;
|
|
29
|
+
--accent: #7c3aed;
|
|
30
|
+
--accentDim: rgba(124, 58, 237, 0.15);
|
|
31
|
+
--green: #22c55e;
|
|
32
|
+
--greenDim: rgba(34, 197, 94, 0.12);
|
|
33
|
+
--amber: #f59e0b;
|
|
34
|
+
--red: #ef4444;
|
|
35
|
+
--radius: 10px;
|
|
36
|
+
--radiusSm: 6px;
|
|
37
|
+
}
|
|
38
|
+
* {
|
|
39
|
+
box-sizing: border-box;
|
|
40
|
+
}
|
|
41
|
+
body {
|
|
42
|
+
margin: 0;
|
|
43
|
+
font-family: 'DM Sans', system-ui, sans-serif;
|
|
44
|
+
background: var(--bg);
|
|
45
|
+
color: var(--text);
|
|
46
|
+
line-height: 1.5;
|
|
47
|
+
min-height: 100vh;
|
|
48
|
+
}
|
|
49
|
+
.page-header {
|
|
50
|
+
padding: 2rem 1.5rem;
|
|
51
|
+
text-align: center;
|
|
52
|
+
border-bottom: 1px solid var(--border);
|
|
53
|
+
}
|
|
54
|
+
.page-header h1 {
|
|
55
|
+
margin: 0;
|
|
56
|
+
font-size: 1.75rem;
|
|
57
|
+
font-weight: 700;
|
|
58
|
+
letter-spacing: -0.02em;
|
|
59
|
+
}
|
|
60
|
+
.page-header p {
|
|
61
|
+
margin: 0.5rem 0 0;
|
|
62
|
+
color: var(--textMuted);
|
|
63
|
+
font-size: 0.95rem;
|
|
64
|
+
}
|
|
65
|
+
.container {
|
|
66
|
+
max-width: 1200px;
|
|
67
|
+
margin: 0 auto;
|
|
68
|
+
padding: 1.5rem;
|
|
69
|
+
}
|
|
70
|
+
.hook-section {
|
|
71
|
+
margin-bottom: 3rem;
|
|
72
|
+
}
|
|
73
|
+
.hook-section h2 {
|
|
74
|
+
margin: 0 0 0.5rem;
|
|
75
|
+
font-size: 1.35rem;
|
|
76
|
+
font-weight: 600;
|
|
77
|
+
color: var(--text);
|
|
78
|
+
}
|
|
79
|
+
.flow {
|
|
80
|
+
display: inline-block;
|
|
81
|
+
margin-bottom: 0.5rem;
|
|
82
|
+
padding: 0.25rem 0.6rem;
|
|
83
|
+
font-size: 0.75rem;
|
|
84
|
+
font-family: 'JetBrains Mono', monospace;
|
|
85
|
+
color: var(--accent);
|
|
86
|
+
background: var(--accentDim);
|
|
87
|
+
border-radius: var(--radiusSm);
|
|
88
|
+
}
|
|
89
|
+
.summary {
|
|
90
|
+
margin: 0 0 1rem;
|
|
91
|
+
font-size: 0.9rem;
|
|
92
|
+
color: var(--textMuted);
|
|
93
|
+
max-width: 60ch;
|
|
94
|
+
}
|
|
95
|
+
.cards {
|
|
96
|
+
display: grid;
|
|
97
|
+
grid-template-columns: 1fr 1fr;
|
|
98
|
+
gap: 1rem;
|
|
99
|
+
}
|
|
100
|
+
@media (max-width: 800px) {
|
|
101
|
+
.cards {
|
|
102
|
+
grid-template-columns: 1fr;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
.card {
|
|
106
|
+
background: var(--surface);
|
|
107
|
+
border: 1px solid var(--border);
|
|
108
|
+
border-radius: var(--radius);
|
|
109
|
+
overflow: hidden;
|
|
110
|
+
}
|
|
111
|
+
.card-title {
|
|
112
|
+
padding: 0.5rem 0.75rem;
|
|
113
|
+
font-size: 0.75rem;
|
|
114
|
+
font-weight: 600;
|
|
115
|
+
text-transform: uppercase;
|
|
116
|
+
letter-spacing: 0.05em;
|
|
117
|
+
color: var(--textMuted);
|
|
118
|
+
border-bottom: 1px solid var(--border);
|
|
119
|
+
}
|
|
120
|
+
.card-code {
|
|
121
|
+
padding: 0.75rem;
|
|
122
|
+
font-family: 'JetBrains Mono', monospace;
|
|
123
|
+
font-size: 0.8rem;
|
|
124
|
+
line-height: 1.45;
|
|
125
|
+
color: var(--textMuted);
|
|
126
|
+
background: #0d0d0f;
|
|
127
|
+
overflow-x: auto;
|
|
128
|
+
white-space: pre;
|
|
129
|
+
}
|
|
130
|
+
.card-live {
|
|
131
|
+
padding: 1rem;
|
|
132
|
+
min-height: 80px;
|
|
133
|
+
}
|
|
134
|
+
.card-live .status {
|
|
135
|
+
font-size: 0.85rem;
|
|
136
|
+
}
|
|
137
|
+
.card-live button {
|
|
138
|
+
padding: 0.4rem 0.75rem;
|
|
139
|
+
margin: 0.2rem 0.2rem 0 0;
|
|
140
|
+
font-family: inherit;
|
|
141
|
+
font-size: 0.85rem;
|
|
142
|
+
background: var(--surface2);
|
|
143
|
+
border: 1px solid var(--border);
|
|
144
|
+
border-radius: var(--radiusSm);
|
|
145
|
+
color: var(--text);
|
|
146
|
+
cursor: pointer;
|
|
147
|
+
}
|
|
148
|
+
.card-live button:hover {
|
|
149
|
+
background: var(--border);
|
|
150
|
+
}
|
|
151
|
+
.card-live button:disabled {
|
|
152
|
+
opacity: 0.5;
|
|
153
|
+
cursor: not-allowed;
|
|
154
|
+
}
|
|
155
|
+
.badge {
|
|
156
|
+
display: inline-block;
|
|
157
|
+
padding: 0.2rem 0.5rem;
|
|
158
|
+
border-radius: 4px;
|
|
159
|
+
font-size: 0.8rem;
|
|
160
|
+
}
|
|
161
|
+
.badge.green {
|
|
162
|
+
background: var(--greenDim);
|
|
163
|
+
color: var(--green);
|
|
164
|
+
}
|
|
165
|
+
.badge.amber {
|
|
166
|
+
color: var(--amber);
|
|
167
|
+
}
|
|
168
|
+
/* IndexedDB demo: reactive table + action buttons */
|
|
169
|
+
.idb-actions {
|
|
170
|
+
display: flex;
|
|
171
|
+
flex-wrap: wrap;
|
|
172
|
+
gap: 0.35rem;
|
|
173
|
+
margin-bottom: 0.75rem;
|
|
174
|
+
}
|
|
175
|
+
.idb-table-viz {
|
|
176
|
+
background: var(--surface2);
|
|
177
|
+
border-radius: var(--radiusSm);
|
|
178
|
+
padding: 0.5rem;
|
|
179
|
+
font-size: 0.8rem;
|
|
180
|
+
max-height: 180px;
|
|
181
|
+
overflow-y: auto;
|
|
182
|
+
}
|
|
183
|
+
.idb-row {
|
|
184
|
+
display: flex;
|
|
185
|
+
align-items: center;
|
|
186
|
+
justify-content: space-between;
|
|
187
|
+
padding: 0.35rem 0.5rem;
|
|
188
|
+
border-radius: 4px;
|
|
189
|
+
margin-bottom: 2px;
|
|
190
|
+
}
|
|
191
|
+
.idb-row:nth-child(odd) {
|
|
192
|
+
background: rgba(255, 255, 255, 0.03);
|
|
193
|
+
}
|
|
194
|
+
.idb-row-actions {
|
|
195
|
+
display: flex;
|
|
196
|
+
gap: 0.25rem;
|
|
197
|
+
}
|
|
198
|
+
.idb-count {
|
|
199
|
+
margin-bottom: 0.5rem;
|
|
200
|
+
font-weight: 600;
|
|
201
|
+
color: var(--textMuted);
|
|
202
|
+
}
|
|
203
|
+
/* useWasmCompute flow diagram */
|
|
204
|
+
.wasm-flow {
|
|
205
|
+
display: flex;
|
|
206
|
+
align-items: center;
|
|
207
|
+
justify-content: center;
|
|
208
|
+
flex-wrap: wrap;
|
|
209
|
+
gap: 0.25rem;
|
|
210
|
+
padding: 0.75rem;
|
|
211
|
+
margin-bottom: 0.75rem;
|
|
212
|
+
background: var(--surface2);
|
|
213
|
+
border-radius: var(--radiusSm);
|
|
214
|
+
}
|
|
215
|
+
.wasm-flow-node {
|
|
216
|
+
padding: 0.3rem 0.5rem;
|
|
217
|
+
font-size: 0.7rem;
|
|
218
|
+
font-family: 'JetBrains Mono', monospace;
|
|
219
|
+
border-radius: 4px;
|
|
220
|
+
border: 1px solid var(--border);
|
|
221
|
+
color: var(--textMuted);
|
|
222
|
+
transition: background 0.2s, border-color 0.2s;
|
|
223
|
+
}
|
|
224
|
+
.wasm-flow-node.active {
|
|
225
|
+
border-color: var(--accent);
|
|
226
|
+
background: var(--accentDim);
|
|
227
|
+
color: var(--accent);
|
|
228
|
+
}
|
|
229
|
+
.wasm-flow-arrow {
|
|
230
|
+
color: var(--textMuted);
|
|
231
|
+
font-size: 0.75rem;
|
|
232
|
+
}
|
|
233
|
+
</style>
|
|
234
|
+
</head>
|
|
235
|
+
<body>
|
|
236
|
+
<header class="page-header">
|
|
237
|
+
<h1>Preact Missing Hooks</h1>
|
|
238
|
+
</header>
|
|
239
|
+
<main class="container" id="root"></main>
|
|
240
|
+
<script type="module" src="./main.js"></script>
|
|
241
|
+
</body>
|
|
242
|
+
</html>
|
package/demo/main.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { h, render } from 'https://esm.sh/preact@10';
|
|
2
|
+
import { useState, useEffect, useRef } from 'https://esm.sh/preact@10/hooks';
|
|
3
|
+
import {
|
|
4
|
+
useTransition,
|
|
5
|
+
useMutationObserver,
|
|
6
|
+
useEventBus,
|
|
7
|
+
useWrappedChildren,
|
|
8
|
+
usePreferredTheme,
|
|
9
|
+
useNetworkState,
|
|
10
|
+
useClipboard,
|
|
11
|
+
useRageClick,
|
|
12
|
+
useThreadedWorker,
|
|
13
|
+
useIndexedDB,
|
|
14
|
+
useWebRTCIP,
|
|
15
|
+
useWasmCompute,
|
|
16
|
+
} from 'https://unpkg.com/preact-missing-hooks/dist/index.module.js';
|
|
17
|
+
|
|
18
|
+
// ——— Hook demo components ———
|
|
19
|
+
|
|
20
|
+
function DemoTransition() {
|
|
21
|
+
const [startTransition, isPending] = useTransition();
|
|
22
|
+
const [count, setCount] = useState(0);
|
|
23
|
+
return h('div', {},
|
|
24
|
+
h('button', { onClick: () => startTransition(() => setCount((c) => c + 1)) }, 'Increment'),
|
|
25
|
+
' ',
|
|
26
|
+
isPending ? h('span', { class: 'badge amber' }, 'Pending…') : null,
|
|
27
|
+
h('span', { style: { marginLeft: '0.5rem' } }, 'Count: ' + count)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function DemoMutationObserver() {
|
|
32
|
+
const ref = useRef(null);
|
|
33
|
+
const [log, setLog] = useState([]);
|
|
34
|
+
useMutationObserver(ref, (mutations) => {
|
|
35
|
+
setLog((prev) => [...prev.slice(-2), mutations.length + ' mutation(s)']);
|
|
36
|
+
}, { childList: true, subtree: true });
|
|
37
|
+
return h('div', {},
|
|
38
|
+
h('div', { ref, style: { padding: '0.5rem', background: 'var(--surface2)', borderRadius: '6px', marginBottom: '0.5rem' } },
|
|
39
|
+
h('span', {}, 'Watch this area → '),
|
|
40
|
+
h('button', { onClick: () => { const el = ref.current; if (el) { const s = document.createElement('span'); s.textContent = ' +new'; el.appendChild(s); } } }, 'Add node')
|
|
41
|
+
),
|
|
42
|
+
h('div', { class: 'status' }, log.length ? log.join(' · ') : 'No mutations yet')
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function DemoEventBus() {
|
|
47
|
+
const { emit, on } = useEventBus();
|
|
48
|
+
const [msg, setMsg] = useState('');
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
return on('greet', setMsg);
|
|
51
|
+
}, [on]);
|
|
52
|
+
return h('div', {},
|
|
53
|
+
h('button', { onClick: () => emit('greet', 'Hello from bus!') }, 'Emit greet'),
|
|
54
|
+
msg ? h('span', { style: { marginLeft: '0.5rem' }, class: 'badge green' }, msg) : null
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function DemoWrappedChildren() {
|
|
59
|
+
const children = [h('button', {}, 'A'), h('button', {}, 'B')];
|
|
60
|
+
const wrapped = useWrappedChildren(children, { style: { marginRight: '0.25rem' } });
|
|
61
|
+
return h('div', {}, wrapped);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function DemoPreferredTheme() {
|
|
65
|
+
const theme = usePreferredTheme();
|
|
66
|
+
return h('div', {}, h('span', { class: 'badge ' + (theme === 'dark' ? 'green' : 'amber') }, theme));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function DemoNetworkState() {
|
|
70
|
+
const state = useNetworkState();
|
|
71
|
+
return h('div', { class: 'status' },
|
|
72
|
+
state.online ? h('span', { class: 'badge green' }, 'Online') : h('span', { class: 'badge', style: { background: 'var(--red)', color: '#fff' } }, 'Offline'),
|
|
73
|
+
state.effectiveType ? ' ' + state.effectiveType : ''
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function DemoClipboard() {
|
|
78
|
+
const { copy, paste, copied, error } = useClipboard();
|
|
79
|
+
const [pasted, setPasted] = useState('');
|
|
80
|
+
return h('div', {},
|
|
81
|
+
h('button', { onClick: () => copy('Hello from useClipboard!') }, copied ? 'Copied!' : 'Copy'),
|
|
82
|
+
' ',
|
|
83
|
+
h('button', { onClick: () => paste().then(setPasted) }, 'Paste'),
|
|
84
|
+
pasted ? h('div', { style: { marginTop: '0.5rem', fontSize: '0.85rem' } }, 'Pasted: ' + pasted) : null,
|
|
85
|
+
error ? h('div', { style: { color: 'var(--red)', fontSize: '0.8rem' } }, error.message) : null
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function DemoRageClick() {
|
|
90
|
+
const ref = useRef(null);
|
|
91
|
+
const [count, setCount] = useState(0);
|
|
92
|
+
useRageClick(ref, { onRageClick: () => setCount((c) => c + 1), threshold: 3 });
|
|
93
|
+
return h('div', {},
|
|
94
|
+
h('div', { ref, style: { padding: '1rem', background: 'var(--surface2)', borderRadius: '6px', cursor: 'pointer', userSelect: 'none' } }, 'Click here 3+ times fast (rage click)'),
|
|
95
|
+
count ? h('div', { class: 'badge amber', style: { marginTop: '0.5rem' } }, 'Rage clicks: ' + count) : null
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function DemoThreadedWorker() {
|
|
100
|
+
const { run, loading, result } = useThreadedWorker(
|
|
101
|
+
(x) => new Promise((r) => setTimeout(() => r('Result: ' + x), 500)),
|
|
102
|
+
{ mode: 'sequential' }
|
|
103
|
+
);
|
|
104
|
+
return h('div', {},
|
|
105
|
+
h('button', { onClick: () => run('task'), disabled: loading }, loading ? 'Running…' : 'Run task'),
|
|
106
|
+
result ? h('span', { style: { marginLeft: '0.5rem' }, class: 'badge green' }, result) : null
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function DemoIndexedDB() {
|
|
111
|
+
const { db, isReady, error } = useIndexedDB({ name: 'demo-db', version: 1, tables: { items: { keyPath: 'id' } } });
|
|
112
|
+
const [items, setItems] = useState([]);
|
|
113
|
+
const [count, setCount] = useState(null);
|
|
114
|
+
|
|
115
|
+
const refresh = () => {
|
|
116
|
+
if (!db) return;
|
|
117
|
+
db.table('items').query(() => true).then(setItems);
|
|
118
|
+
db.table('items').count().then(setCount);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!db || !isReady) return;
|
|
123
|
+
refresh();
|
|
124
|
+
}, [db, isReady]);
|
|
125
|
+
|
|
126
|
+
const insert = () => db && db.table('items').insert({ id: Date.now(), label: 'Item ' + (items.length + 1), created: Date.now() }).then(refresh);
|
|
127
|
+
const bulkInsert = () => db && db.table('items').bulkInsert([
|
|
128
|
+
{ id: Date.now() + 1, label: 'Bulk A', created: Date.now() },
|
|
129
|
+
{ id: Date.now() + 2, label: 'Bulk B', created: Date.now() },
|
|
130
|
+
]).then(refresh);
|
|
131
|
+
const update = (id, label) => db && db.table('items').update(id, { label }).then(refresh);
|
|
132
|
+
const remove = (id) => db && db.table('items').delete(id).then(refresh);
|
|
133
|
+
const clear = () => db && db.table('items').clear().then(refresh);
|
|
134
|
+
|
|
135
|
+
if (error) return h('div', { style: { color: 'var(--red)' } }, error.message);
|
|
136
|
+
if (!isReady) return h('div', { class: 'status' }, 'Opening DB…');
|
|
137
|
+
|
|
138
|
+
return h('div', {},
|
|
139
|
+
h('div', { class: 'idb-actions' }, [
|
|
140
|
+
h('button', { onClick: insert }, 'Insert'),
|
|
141
|
+
h('button', { onClick: bulkInsert }, 'Bulk insert'),
|
|
142
|
+
h('button', { onClick: refresh }, 'Query all'),
|
|
143
|
+
h('button', { onClick: clear }, 'Clear'),
|
|
144
|
+
]),
|
|
145
|
+
h('div', { class: 'idb-count' }, 'Count: ' + (count ?? '—')),
|
|
146
|
+
h('div', { class: 'idb-table-viz' },
|
|
147
|
+
items.length === 0
|
|
148
|
+
? h('div', { style: { color: 'var(--textMuted)', padding: '0.5rem' } }, 'No rows. Use Insert or Bulk insert.')
|
|
149
|
+
: items.map((row) =>
|
|
150
|
+
h('div', { key: row.id, class: 'idb-row' }, [
|
|
151
|
+
h('span', {}, row.label || 'id:' + row.id),
|
|
152
|
+
h('div', { class: 'idb-row-actions' }, [
|
|
153
|
+
h('button', { onClick: () => update(row.id, (row.label || '') + '✓') }, 'Update'),
|
|
154
|
+
h('button', { onClick: () => remove(row.id) }, 'Delete'),
|
|
155
|
+
])
|
|
156
|
+
])
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function DemoWebRTCIP() {
|
|
163
|
+
const { ips, loading, error } = useWebRTCIP({ timeout: 4000 });
|
|
164
|
+
if (loading) return h('div', { class: 'status' }, 'Detecting IP…');
|
|
165
|
+
if (error) return h('div', { style: { color: 'var(--red)', fontSize: '0.85rem' } }, error);
|
|
166
|
+
return h('div', {}, ips.length ? h('span', { class: 'badge green' }, ips.join(', ')) : 'No IPs');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function DemoWasmCompute() {
|
|
170
|
+
const { compute, result, loading, error, ready } = useWasmCompute({
|
|
171
|
+
wasmUrl: new URL('./add.wasm', import.meta.url).href,
|
|
172
|
+
exportName: 'compute',
|
|
173
|
+
});
|
|
174
|
+
const flowSteps = [
|
|
175
|
+
{ id: 'component', label: 'Component' },
|
|
176
|
+
{ id: 'hook', label: 'useWasmCompute()' },
|
|
177
|
+
{ id: 'worker', label: 'Web Worker' },
|
|
178
|
+
{ id: 'wasm', label: 'WASM' },
|
|
179
|
+
{ id: 'result', label: 'result' },
|
|
180
|
+
];
|
|
181
|
+
const activeId = result != null ? 'result' : ready ? (loading ? 'wasm' : 'wasm') : 'worker';
|
|
182
|
+
|
|
183
|
+
if (error) return h('div', { style: { color: 'var(--red)' } }, error);
|
|
184
|
+
return h('div', {},
|
|
185
|
+
h('div', { class: 'wasm-flow' },
|
|
186
|
+
flowSteps.map((s, i) => [
|
|
187
|
+
i > 0 && h('span', { class: 'wasm-flow-arrow' }, '→'),
|
|
188
|
+
h('span', { class: 'wasm-flow-node' + (s.id === activeId ? ' active' : '') }, s.label),
|
|
189
|
+
]).flat()
|
|
190
|
+
),
|
|
191
|
+
!ready && h('div', { class: 'status' }, 'Loading WASM in worker…'),
|
|
192
|
+
ready && h('div', { style: { marginBottom: '0.5rem' } }, [
|
|
193
|
+
h('button', { onClick: () => compute(41), disabled: loading }, loading ? 'Computing…' : 'compute(41)'),
|
|
194
|
+
h('button', { onClick: () => compute(100), disabled: loading }, 'compute(100)'),
|
|
195
|
+
h('button', { onClick: () => compute(0), disabled: loading }, 'compute(0)'),
|
|
196
|
+
]),
|
|
197
|
+
result != null && h('div', { class: 'badge green', style: { marginTop: '0.5rem' } }, 'Result: ' + result)
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ——— Page data: heading, flow, summary, code, LiveComponent ———
|
|
202
|
+
|
|
203
|
+
const HOOKS = [
|
|
204
|
+
{
|
|
205
|
+
name: 'useTransition',
|
|
206
|
+
flow: 'Component → useTransition() → startTransition(cb) → deferred state update',
|
|
207
|
+
summary: 'Defers state updates so the UI stays responsive. Returns [startTransition, isPending].',
|
|
208
|
+
code: `const [startTransition, isPending] = useTransition();\nstartTransition(() => setCount(c => c + 1));`,
|
|
209
|
+
Live: DemoTransition,
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: 'useMutationObserver',
|
|
213
|
+
flow: 'Component → ref + useMutationObserver(ref, callback, options) → DOM changes trigger callback',
|
|
214
|
+
summary: 'Observes DOM mutations (childList, attributes, etc.) on the element attached to ref.',
|
|
215
|
+
code: `const ref = useRef(null);\nuseMutationObserver(ref, (mutations) => { ... }, { childList: true });`,
|
|
216
|
+
Live: DemoMutationObserver,
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: 'useEventBus',
|
|
220
|
+
flow: 'Components → useEventBus() → emit(name, ...args) / on(name, fn) → cross-component events',
|
|
221
|
+
summary: 'Publish/subscribe event bus so components can talk without prop drilling.',
|
|
222
|
+
code: `const { emit, on } = useEventBus();\non('greet', setMsg);\nemit('greet', 'Hello');`,
|
|
223
|
+
Live: DemoEventBus,
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: 'useWrappedChildren',
|
|
227
|
+
flow: 'Parent → useWrappedChildren(children, props) → children with merged props',
|
|
228
|
+
summary: 'Injects props into every child (e.g. style, className) with preserve or override strategy.',
|
|
229
|
+
code: `const wrapped = useWrappedChildren(children, { style: { marginRight: 8 } });`,
|
|
230
|
+
Live: DemoWrappedChildren,
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: 'usePreferredTheme',
|
|
234
|
+
flow: 'Component → usePreferredTheme() → matchMedia(prefers-color-scheme) → theme',
|
|
235
|
+
summary: 'Returns the user’s preferred color scheme: light, dark, or no-preference.',
|
|
236
|
+
code: `const theme = usePreferredTheme(); // 'light' | 'dark' | 'no-preference'`,
|
|
237
|
+
Live: DemoPreferredTheme,
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'useNetworkState',
|
|
241
|
+
flow: 'Component → useNetworkState() → online + connection (effectiveType, etc.)',
|
|
242
|
+
summary: 'Tracks online/offline and connection type (when the Network Information API is available).',
|
|
243
|
+
code: `const state = useNetworkState();\n// state.online, state.effectiveType, ...`,
|
|
244
|
+
Live: DemoNetworkState,
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'useClipboard',
|
|
248
|
+
flow: 'Component → useClipboard() → copy(text) / paste() → Clipboard API',
|
|
249
|
+
summary: 'Copy and paste text with the Clipboard API; returns copied and error state.',
|
|
250
|
+
code: `const { copy, copied, paste } = useClipboard();\ncopy('Hello');`,
|
|
251
|
+
Live: DemoClipboard,
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: 'useRageClick',
|
|
255
|
+
flow: 'ref + useRageClick(ref, { onRageClick }) → N fast clicks in same spot → callback',
|
|
256
|
+
summary: 'Detects rage clicks (e.g. for Sentry) when the user clicks repeatedly in the same area.',
|
|
257
|
+
code: `useRageClick(ref, { onRageClick: (p) => report(p.count), threshold: 5 });`,
|
|
258
|
+
Live: DemoRageClick,
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: 'useThreadedWorker',
|
|
262
|
+
flow: 'useThreadedWorker(fn, { mode }) → run(data) → queue → fn runs → result',
|
|
263
|
+
summary: 'Runs async work in a queue; sequential or parallel mode with optional priority.',
|
|
264
|
+
code: `const { run, loading, result } = useThreadedWorker(fn, { mode: 'sequential' });\nrun(data);`,
|
|
265
|
+
Live: DemoThreadedWorker,
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
name: 'useIndexedDB',
|
|
269
|
+
flow: 'useIndexedDB({ name, version, tables }) → db.table(name).insert/query/...',
|
|
270
|
+
summary: 'IndexedDB with tables, insert, update, delete, query, count, and transactions.',
|
|
271
|
+
code: `const { db, isReady } = useIndexedDB({ name: 'my-db', tables: { items: { keyPath: 'id' } } });\ndb.table('items').insert({ id: 1 });`,
|
|
272
|
+
Live: DemoIndexedDB,
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: 'useWebRTCIP',
|
|
276
|
+
flow: 'useWebRTCIP() → STUN + ICE candidates → extract IPv4 → ips[]',
|
|
277
|
+
summary: 'Tries to detect client IP via WebRTC; use as a hint and fall back to an IP API if needed.',
|
|
278
|
+
code: `const { ips, loading, error } = useWebRTCIP({ timeout: 3000 });`,
|
|
279
|
+
Live: DemoWebRTCIP,
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: 'useWasmCompute',
|
|
283
|
+
flow: 'Component → useWasmCompute({ wasmUrl }) → Web Worker → WASM → compute(input) → result',
|
|
284
|
+
summary: 'Runs WebAssembly in a worker; returns compute(input), result, loading, ready.',
|
|
285
|
+
code: `const { compute, result, ready } = useWasmCompute({ wasmUrl: '/add.wasm' });\ncompute(41); // → 42`,
|
|
286
|
+
Live: DemoWasmCompute,
|
|
287
|
+
},
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
function App() {
|
|
291
|
+
return h('div', {},
|
|
292
|
+
HOOKS.map((hook) =>
|
|
293
|
+
h('section', { key: hook.name, class: 'hook-section' }, [
|
|
294
|
+
h('h2', {}, hook.name),
|
|
295
|
+
h('div', { class: 'flow' }, hook.flow),
|
|
296
|
+
h('p', { class: 'summary' }, hook.summary),
|
|
297
|
+
h('div', { class: 'cards' }, [
|
|
298
|
+
h('div', { class: 'card' }, [
|
|
299
|
+
h('div', { class: 'card-title' }, 'Example'),
|
|
300
|
+
h('pre', { class: 'card-code' }, hook.code),
|
|
301
|
+
]),
|
|
302
|
+
h('div', { class: 'card' }, [
|
|
303
|
+
h('div', { class: 'card-title' }, 'Live'),
|
|
304
|
+
h('div', { class: 'card-live' }, h(hook.Live)),
|
|
305
|
+
]),
|
|
306
|
+
]),
|
|
307
|
+
])
|
|
308
|
+
)
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
render(h(App), document.getElementById('root'));
|