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 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), and a production-ready **IndexedDB** hook with tables, transactions, and a full CRUD API.
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
@@ -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'));
package/dist/index.d.ts CHANGED
@@ -8,3 +8,5 @@ export * from './useClipboard';
8
8
  export * from './useRageClick';
9
9
  export * from './useThreadedWorker';
10
10
  export * from './useIndexedDB';
11
+ export * from './useWebRTCIP';
12
+ export * from './useWasmCompute';