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.
Files changed (79) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.husky/pre-push +1 -0
  3. package/.prettierignore +3 -0
  4. package/.prettierrc +6 -0
  5. package/Readme.md +179 -131
  6. package/dist/entry.cjs +21 -0
  7. package/dist/entry.js +2 -0
  8. package/dist/entry.js.map +1 -0
  9. package/dist/entry.modern.mjs +2 -0
  10. package/dist/entry.modern.mjs.map +1 -0
  11. package/dist/entry.module.js +2 -0
  12. package/dist/entry.module.js.map +1 -0
  13. package/dist/entry.umd.js +2 -0
  14. package/dist/entry.umd.js.map +1 -0
  15. package/dist/index.d.ts +13 -12
  16. package/dist/index.js +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/index.modern.mjs +2 -0
  19. package/dist/index.modern.mjs.map +1 -0
  20. package/dist/index.module.js +1 -1
  21. package/dist/index.module.js.map +1 -1
  22. package/dist/index.umd.js +1 -1
  23. package/dist/index.umd.js.map +1 -1
  24. package/dist/indexedDB/dbController.d.ts +2 -2
  25. package/dist/indexedDB/index.d.ts +6 -6
  26. package/dist/indexedDB/openDB.d.ts +1 -1
  27. package/dist/indexedDB/tableController.d.ts +1 -1
  28. package/dist/indexedDB/types.d.ts +1 -2
  29. package/dist/react.js +1 -0
  30. package/dist/react.modern.mjs +1 -0
  31. package/dist/react.module.js +1 -0
  32. package/dist/react.umd.js +1 -0
  33. package/dist/useEventBus.d.ts +1 -1
  34. package/dist/useIndexedDB.d.ts +3 -3
  35. package/dist/useMutationObserver.d.ts +1 -1
  36. package/dist/useNetworkState.d.ts +3 -3
  37. package/dist/usePreferredTheme.d.ts +1 -1
  38. package/dist/useRageClick.d.ts +1 -1
  39. package/dist/useThreadedWorker.d.ts +1 -1
  40. package/dist/useTransition.d.ts +4 -1
  41. package/dist/useWorkerNotifications.d.ts +57 -0
  42. package/dist/useWrappedChildren.d.ts +3 -3
  43. package/{demo → docs}/index.html +56 -0
  44. package/{demo → docs}/main.js +437 -312
  45. package/eslint.config.mjs +10 -0
  46. package/package.json +65 -6
  47. package/scripts/generate-entry.cjs +34 -0
  48. package/src/index.ts +13 -12
  49. package/src/indexedDB/dbController.ts +101 -92
  50. package/src/indexedDB/index.ts +16 -11
  51. package/src/indexedDB/openDB.ts +49 -49
  52. package/src/indexedDB/requestToPromise.ts +17 -16
  53. package/src/indexedDB/tableController.ts +331 -257
  54. package/src/indexedDB/types.ts +35 -35
  55. package/src/useClipboard.ts +99 -97
  56. package/src/useEventBus.ts +39 -36
  57. package/src/useIndexedDB.ts +111 -111
  58. package/src/useMutationObserver.ts +26 -26
  59. package/src/useNetworkState.ts +124 -122
  60. package/src/usePreferredTheme.ts +68 -68
  61. package/src/useRageClick.ts +103 -103
  62. package/src/useThreadedWorker.ts +165 -165
  63. package/src/useTransition.ts +22 -19
  64. package/src/useWasmCompute.ts +209 -204
  65. package/src/useWebRTCIP.ts +181 -176
  66. package/src/useWorkerNotifications.ts +203 -0
  67. package/src/useWrappedChildren.ts +72 -58
  68. package/tests/react-adapter.tsx +12 -0
  69. package/tests/setup-react.ts +4 -0
  70. package/tests/useClipboard.test.tsx +4 -2
  71. package/tests/useThreadedWorker.test.tsx +3 -1
  72. package/tests/useWasmCompute.test.tsx +1 -1
  73. package/tests/useWebRTCIP.test.tsx +3 -1
  74. package/tests/useWorkerNotifications.test.tsx +170 -0
  75. package/vite.config.ts +11 -4
  76. package/vitest.config.preact.ts +20 -0
  77. package/vitest.config.react.ts +36 -0
  78. package/vitest.workspace.ts +6 -0
  79. /package/{demo → docs}/add.wasm +0 -0
@@ -1,312 +1,437 @@
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'));
1
+ import { h, render } from 'https://esm.sh/preact@10';
2
+ import { useState, useEffect, useRef } from 'https://esm.sh/preact@10/hooks';
3
+
4
+ const isLocal =
5
+ typeof window !== 'undefined' &&
6
+ (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
7
+ const {
8
+ useTransition,
9
+ useMutationObserver,
10
+ useEventBus,
11
+ useWrappedChildren,
12
+ usePreferredTheme,
13
+ useNetworkState,
14
+ useClipboard,
15
+ useRageClick,
16
+ useThreadedWorker,
17
+ useIndexedDB,
18
+ useWebRTCIP,
19
+ useWasmCompute,
20
+ useWorkerNotifications,
21
+ } = await import(
22
+ isLocal ? '../dist/index.module.js' : 'https://unpkg.com/preact-missing-hooks/dist/index.module.js'
23
+ );
24
+
25
+ // ——— Hook demo components ———
26
+
27
+ function DemoTransition() {
28
+ const [startTransition, isPending] = useTransition();
29
+ const [count, setCount] = useState(0);
30
+ return h('div', {},
31
+ h('button', { onClick: () => startTransition(() => setCount((c) => c + 1)) }, 'Increment'),
32
+ ' ',
33
+ isPending ? h('span', { class: 'badge amber' }, 'Pending…') : null,
34
+ h('span', { style: { marginLeft: '0.5rem' } }, 'Count: ' + count)
35
+ );
36
+ }
37
+
38
+ function DemoMutationObserver() {
39
+ const ref = useRef(null);
40
+ const [log, setLog] = useState([]);
41
+ useMutationObserver(ref, (mutations) => {
42
+ setLog((prev) => [...prev.slice(-2), mutations.length + ' mutation(s)']);
43
+ }, { childList: true, subtree: true });
44
+ return h('div', {},
45
+ h('div', { ref, style: { padding: '0.5rem', background: 'var(--surface2)', borderRadius: '6px', marginBottom: '0.5rem' } },
46
+ h('span', {}, 'Watch this area → '),
47
+ h('button', { onClick: () => { const el = ref.current; if (el) { const s = document.createElement('span'); s.textContent = ' +new'; el.appendChild(s); } } }, 'Add node')
48
+ ),
49
+ h('div', { class: 'status' }, log.length ? log.join(' · ') : 'No mutations yet')
50
+ );
51
+ }
52
+
53
+ function DemoEventBus() {
54
+ const { emit, on } = useEventBus();
55
+ const [msg, setMsg] = useState('');
56
+ useEffect(() => {
57
+ return on('greet', setMsg);
58
+ }, [on]);
59
+ return h('div', {},
60
+ h('button', { onClick: () => emit('greet', 'Hello from bus!') }, 'Emit greet'),
61
+ msg ? h('span', { style: { marginLeft: '0.5rem' }, class: 'badge green' }, msg) : null
62
+ );
63
+ }
64
+
65
+ function DemoWrappedChildren() {
66
+ const children = [h('button', {}, 'A'), h('button', {}, 'B')];
67
+ const wrapped = useWrappedChildren(children, { style: { marginRight: '0.25rem' } });
68
+ return h('div', {}, wrapped);
69
+ }
70
+
71
+ function DemoPreferredTheme() {
72
+ const theme = usePreferredTheme();
73
+ return h('div', {}, h('span', { class: 'badge ' + (theme === 'dark' ? 'green' : 'amber') }, theme));
74
+ }
75
+
76
+ function DemoNetworkState() {
77
+ const state = useNetworkState();
78
+ return h('div', { class: 'status' },
79
+ state.online ? h('span', { class: 'badge green' }, 'Online') : h('span', { class: 'badge', style: { background: 'var(--red)', color: '#fff' } }, 'Offline'),
80
+ state.effectiveType ? ' ' + state.effectiveType : ''
81
+ );
82
+ }
83
+
84
+ function DemoClipboard() {
85
+ const { copy, paste, copied, error } = useClipboard();
86
+ const [pasted, setPasted] = useState('');
87
+ return h('div', {},
88
+ h('button', { onClick: () => copy('Hello from useClipboard!') }, copied ? 'Copied!' : 'Copy'),
89
+ ' ',
90
+ h('button', { onClick: () => paste().then(setPasted) }, 'Paste'),
91
+ pasted ? h('div', { style: { marginTop: '0.5rem', fontSize: '0.85rem' } }, 'Pasted: ' + pasted) : null,
92
+ error ? h('div', { style: { color: 'var(--red)', fontSize: '0.8rem' } }, error.message) : null
93
+ );
94
+ }
95
+
96
+ function DemoRageClick() {
97
+ const ref = useRef(null);
98
+ const [count, setCount] = useState(0);
99
+ useRageClick(ref, { onRageClick: () => setCount((c) => c + 1), threshold: 3 });
100
+ return h('div', {},
101
+ h('div', { ref, style: { padding: '1rem', background: 'var(--surface2)', borderRadius: '6px', cursor: 'pointer', userSelect: 'none' } }, 'Click here 3+ times fast (rage click)'),
102
+ count ? h('div', { class: 'badge amber', style: { marginTop: '0.5rem' } }, 'Rage clicks: ' + count) : null
103
+ );
104
+ }
105
+
106
+ function DemoThreadedWorker() {
107
+ const { run, loading, result } = useThreadedWorker(
108
+ (x) => new Promise((r) => setTimeout(() => r('Result: ' + x), 500)),
109
+ { mode: 'sequential' }
110
+ );
111
+ return h('div', {},
112
+ h('button', { onClick: () => run('task'), disabled: loading }, loading ? 'Running…' : 'Run task'),
113
+ result ? h('span', { style: { marginLeft: '0.5rem' }, class: 'badge green' }, result) : null
114
+ );
115
+ }
116
+
117
+ function DemoIndexedDB() {
118
+ const { db, isReady, error } = useIndexedDB({ name: 'demo-db', version: 1, tables: { items: { keyPath: 'id' } } });
119
+ const [items, setItems] = useState([]);
120
+ const [count, setCount] = useState(null);
121
+
122
+ const refresh = () => {
123
+ if (!db) return;
124
+ db.table('items').query(() => true).then(setItems);
125
+ db.table('items').count().then(setCount);
126
+ };
127
+
128
+ useEffect(() => {
129
+ if (!db || !isReady) return;
130
+ refresh();
131
+ }, [db, isReady]);
132
+
133
+ const insert = () => db && db.table('items').insert({ id: Date.now(), label: 'Item ' + (items.length + 1), created: Date.now() }).then(refresh);
134
+ const bulkInsert = () => db && db.table('items').bulkInsert([
135
+ { id: Date.now() + 1, label: 'Bulk A', created: Date.now() },
136
+ { id: Date.now() + 2, label: 'Bulk B', created: Date.now() },
137
+ ]).then(refresh);
138
+ const update = (id, label) => db && db.table('items').update(id, { label }).then(refresh);
139
+ const remove = (id) => db && db.table('items').delete(id).then(refresh);
140
+ const clear = () => db && db.table('items').clear().then(refresh);
141
+
142
+ if (error) return h('div', { style: { color: 'var(--red)' } }, error.message);
143
+ if (!isReady) return h('div', { class: 'status' }, 'Opening DB…');
144
+
145
+ return h('div', {},
146
+ h('div', { class: 'idb-actions' }, [
147
+ h('button', { onClick: insert }, 'Insert'),
148
+ h('button', { onClick: bulkInsert }, 'Bulk insert'),
149
+ h('button', { onClick: refresh }, 'Query all'),
150
+ h('button', { onClick: clear }, 'Clear'),
151
+ ]),
152
+ h('div', { class: 'idb-count' }, 'Count: ' + (count ?? '—')),
153
+ h('div', { class: 'idb-table-viz' },
154
+ items.length === 0
155
+ ? h('div', { style: { color: 'var(--textMuted)', padding: '0.5rem' } }, 'No rows. Use Insert or Bulk insert.')
156
+ : items.map((row) =>
157
+ h('div', { key: row.id, class: 'idb-row' }, [
158
+ h('span', {}, row.label || 'id:' + row.id),
159
+ h('div', { class: 'idb-row-actions' }, [
160
+ h('button', { onClick: () => update(row.id, (row.label || '') + '✓') }, 'Update'),
161
+ h('button', { onClick: () => remove(row.id) }, 'Delete'),
162
+ ])
163
+ ])
164
+ )
165
+ )
166
+ );
167
+ }
168
+
169
+ function DemoWebRTCIP() {
170
+ const { ips, loading, error } = useWebRTCIP({ timeout: 4000 });
171
+ if (loading) return h('div', { class: 'status' }, 'Detecting IP…');
172
+ if (error) return h('div', { style: { color: 'var(--red)', fontSize: '0.85rem' } }, error);
173
+ return h('div', {}, ips.length ? h('span', { class: 'badge green' }, ips.join(', ')) : 'No IPs');
174
+ }
175
+
176
+ function DemoWasmCompute() {
177
+ const { compute, result, loading, error, ready } = useWasmCompute({
178
+ wasmUrl: new URL('./add.wasm', import.meta.url).href,
179
+ exportName: 'compute',
180
+ });
181
+ const flowSteps = [
182
+ { id: 'component', label: 'Component' },
183
+ { id: 'hook', label: 'useWasmCompute()' },
184
+ { id: 'worker', label: 'Web Worker' },
185
+ { id: 'wasm', label: 'WASM' },
186
+ { id: 'result', label: 'result' },
187
+ ];
188
+ const activeId = result != null ? 'result' : ready ? (loading ? 'wasm' : 'wasm') : 'worker';
189
+
190
+ if (error) return h('div', { style: { color: 'var(--red)' } }, error);
191
+ return h('div', {},
192
+ h('div', { class: 'wasm-flow' },
193
+ flowSteps.map((s, i) => [
194
+ i > 0 && h('span', { class: 'wasm-flow-arrow' }, ''),
195
+ h('span', { class: 'wasm-flow-node' + (s.id === activeId ? ' active' : '') }, s.label),
196
+ ]).flat()
197
+ ),
198
+ !ready && h('div', { class: 'status' }, 'Loading WASM in worker…'),
199
+ ready && h('div', { style: { marginBottom: '0.5rem' } }, [
200
+ h('button', { onClick: () => compute(41), disabled: loading }, loading ? 'Computing…' : 'compute(41)'),
201
+ h('button', { onClick: () => compute(100), disabled: loading }, 'compute(100)'),
202
+ h('button', { onClick: () => compute(0), disabled: loading }, 'compute(0)'),
203
+ ]),
204
+ result != null && h('div', { class: 'badge green', style: { marginTop: '0.5rem' } }, 'Result: ' + result)
205
+ );
206
+ }
207
+
208
+ const DEMO_WORKER_SCRIPT = `
209
+ self.onmessage = (e) => {
210
+ const d = e.data;
211
+ if (d.cmd === 'run' && d.taskId) {
212
+ self.postMessage({ type: 'task_start', taskId: d.taskId });
213
+ const start = Date.now();
214
+ setTimeout(() => {
215
+ self.postMessage({ type: 'task_end', taskId: d.taskId, duration: Date.now() - start });
216
+ }, 100);
217
+ } else if (d.cmd === 'fail' && d.taskId) {
218
+ self.postMessage({ type: 'task_start', taskId: d.taskId });
219
+ self.postMessage({ type: 'task_fail', taskId: d.taskId, error: 'Demo fail' });
220
+ } else if (d.cmd === 'queue' && typeof d.size === 'number') {
221
+ self.postMessage({ type: 'queue_size', size: d.size });
222
+ }
223
+ };
224
+ `;
225
+
226
+ function DemoWorkerNotifications() {
227
+ const [worker, setWorker] = useState(null);
228
+ const [toasts, setToasts] = useState([]);
229
+ const taskIdRef = useRef(0);
230
+ const toastIdRef = useRef(0);
231
+ const eventHistoryLenRef = useRef(0);
232
+
233
+ useEffect(() => {
234
+ const blob = new Blob([DEMO_WORKER_SCRIPT], { type: 'application/javascript' });
235
+ const url = URL.createObjectURL(blob);
236
+ const w = new Worker(url);
237
+ setWorker(w);
238
+ return () => { URL.revokeObjectURL(url); w.terminate(); };
239
+ }, []);
240
+
241
+ const stats = useWorkerNotifications(worker, { maxHistory: 20 });
242
+ const { progress, eventHistory } = stats;
243
+
244
+ useEffect(() => {
245
+ const history = eventHistory;
246
+ const prevLen = eventHistoryLenRef.current;
247
+ if (history.length <= prevLen) return;
248
+ const newEvents = history.slice(prevLen);
249
+ eventHistoryLenRef.current = history.length;
250
+
251
+ const TOAST_TTL = 3000;
252
+ newEvents.forEach((ev) => {
253
+ const id = ++toastIdRef.current;
254
+ let type = 'queue-update';
255
+ let message = '';
256
+ if (ev.type === 'task_start') {
257
+ type = 'running';
258
+ message = 'Running ' + (ev.taskId || 'task');
259
+ } else if (ev.type === 'task_end') {
260
+ type = 'completed';
261
+ message = 'Completed ' + (ev.taskId || 'task') + (ev.duration != null ? ' (' + ev.duration + 'ms)' : '');
262
+ } else if (ev.type === 'task_fail') {
263
+ type = 'failed';
264
+ message = 'Failed ' + (ev.taskId || 'task') + (ev.error ? ': ' + ev.error : '');
265
+ } else if (ev.type === 'queue_size' && ev.size != null) {
266
+ type = 'queued';
267
+ message = 'Queue size: ' + ev.size;
268
+ }
269
+ if (!message) return;
270
+ setToasts((t) => [...t, { id, type, message }]);
271
+ setTimeout(() => {
272
+ setToasts((t) => t.filter((x) => x.id !== id));
273
+ }, TOAST_TTL);
274
+ });
275
+ }, [eventHistory]);
276
+
277
+ const runTask = () => {
278
+ if (!worker) return;
279
+ const id = 't' + ++taskIdRef.current;
280
+ worker.postMessage({ cmd: 'run', taskId: id });
281
+ };
282
+ const failTask = () => {
283
+ if (!worker) return;
284
+ worker.postMessage({ cmd: 'fail', taskId: 'fail-' + ++taskIdRef.current });
285
+ };
286
+ const setQueue = (n) => worker && worker.postMessage({ cmd: 'queue', size: n });
287
+
288
+ if (!worker) return h('div', { class: 'status' }, 'Starting worker…');
289
+
290
+ return h('div', {},
291
+ h('div', { style: { marginBottom: '0.5rem', fontSize: '0.85rem' } }, [
292
+ h('button', { onClick: runTask }, 'Run task'),
293
+ ' ',
294
+ h('button', { onClick: failTask }, 'Fail task'),
295
+ ' ',
296
+ h('button', { onClick: () => setQueue(3) }, 'Queue=3'),
297
+ ' ',
298
+ h('button', { onClick: () => setQueue(0) }, 'Queue=0'),
299
+ ]),
300
+ h('div', { class: 'status', style: { background: 'var(--surface2)', padding: '0.5rem', borderRadius: '6px' } }, [
301
+ h('strong', {}, 'Progress (default): '),
302
+ ' running: ' + progress.runningTasks.length,
303
+ ' | completed: ' + progress.completedCount,
304
+ ' | failed: ' + progress.failedCount,
305
+ ' | total: ' + progress.totalProcessed,
306
+ ' | avg ms: ' + progress.averageDurationMs.toFixed(0),
307
+ ' | throughput/s: ' + progress.throughputPerSecond.toFixed(2),
308
+ ' | queue: ' + progress.currentQueueSize,
309
+ ]),
310
+ h('div', { style: { marginTop: '0.35rem', fontSize: '0.8rem', color: 'var(--textMuted)' } },
311
+ 'Events: ' + stats.eventHistory.length + ' | Running: [' + stats.runningTasks.join(', ') + ']'
312
+ ),
313
+ h('div', { class: 'worker-toast-container' },
314
+ toasts.map((t) => h('div', { key: t.id, class: 'worker-toast ' + t.type }, t.message))
315
+ )
316
+ );
317
+ }
318
+
319
+ // ——— Page data: heading, flow, summary, code, LiveComponent ———
320
+
321
+ const HOOKS = [
322
+ {
323
+ name: 'useTransition',
324
+ flow: 'Component → useTransition() → startTransition(cb) → deferred state update',
325
+ summary: 'Defers state updates so the UI stays responsive. Returns [startTransition, isPending].',
326
+ code: `const [startTransition, isPending] = useTransition();\nstartTransition(() => setCount(c => c + 1));`,
327
+ Live: DemoTransition,
328
+ },
329
+ {
330
+ name: 'useMutationObserver',
331
+ flow: 'Component → ref + useMutationObserver(ref, callback, options) → DOM changes trigger callback',
332
+ summary: 'Observes DOM mutations (childList, attributes, etc.) on the element attached to ref.',
333
+ code: `const ref = useRef(null);\nuseMutationObserver(ref, (mutations) => { ... }, { childList: true });`,
334
+ Live: DemoMutationObserver,
335
+ },
336
+ {
337
+ name: 'useEventBus',
338
+ flow: 'Components → useEventBus() → emit(name, ...args) / on(name, fn) → cross-component events',
339
+ summary: 'Publish/subscribe event bus so components can talk without prop drilling.',
340
+ code: `const { emit, on } = useEventBus();\non('greet', setMsg);\nemit('greet', 'Hello');`,
341
+ Live: DemoEventBus,
342
+ },
343
+ {
344
+ name: 'useWrappedChildren',
345
+ flow: 'Parent → useWrappedChildren(children, props) → children with merged props',
346
+ summary: 'Injects props into every child (e.g. style, className) with preserve or override strategy.',
347
+ code: `const wrapped = useWrappedChildren(children, { style: { marginRight: 8 } });`,
348
+ Live: DemoWrappedChildren,
349
+ },
350
+ {
351
+ name: 'usePreferredTheme',
352
+ flow: 'Component → usePreferredTheme() → matchMedia(prefers-color-scheme) → theme',
353
+ summary: 'Returns the user’s preferred color scheme: light, dark, or no-preference.',
354
+ code: `const theme = usePreferredTheme(); // 'light' | 'dark' | 'no-preference'`,
355
+ Live: DemoPreferredTheme,
356
+ },
357
+ {
358
+ name: 'useNetworkState',
359
+ flow: 'Component → useNetworkState() → online + connection (effectiveType, etc.)',
360
+ summary: 'Tracks online/offline and connection type (when the Network Information API is available).',
361
+ code: `const state = useNetworkState();\n// state.online, state.effectiveType, ...`,
362
+ Live: DemoNetworkState,
363
+ },
364
+ {
365
+ name: 'useClipboard',
366
+ flow: 'Component → useClipboard() → copy(text) / paste() → Clipboard API',
367
+ summary: 'Copy and paste text with the Clipboard API; returns copied and error state.',
368
+ code: `const { copy, copied, paste } = useClipboard();\ncopy('Hello');`,
369
+ Live: DemoClipboard,
370
+ },
371
+ {
372
+ name: 'useRageClick',
373
+ flow: 'ref + useRageClick(ref, { onRageClick }) → N fast clicks in same spot → callback',
374
+ summary: 'Detects rage clicks (e.g. for Sentry) when the user clicks repeatedly in the same area.',
375
+ code: `useRageClick(ref, { onRageClick: (p) => report(p.count), threshold: 5 });`,
376
+ Live: DemoRageClick,
377
+ },
378
+ {
379
+ name: 'useThreadedWorker',
380
+ flow: 'useThreadedWorker(fn, { mode }) → run(data) → queue → fn runs → result',
381
+ summary: 'Runs async work in a queue; sequential or parallel mode with optional priority.',
382
+ code: `const { run, loading, result } = useThreadedWorker(fn, { mode: 'sequential' });\nrun(data);`,
383
+ Live: DemoThreadedWorker,
384
+ },
385
+ {
386
+ name: 'useIndexedDB',
387
+ flow: 'useIndexedDB({ name, version, tables }) → db.table(name).insert/query/...',
388
+ summary: 'IndexedDB with tables, insert, update, delete, query, count, and transactions.',
389
+ code: `const { db, isReady } = useIndexedDB({ name: 'my-db', tables: { items: { keyPath: 'id' } } });\ndb.table('items').insert({ id: 1 });`,
390
+ Live: DemoIndexedDB,
391
+ },
392
+ {
393
+ name: 'useWebRTCIP',
394
+ flow: 'useWebRTCIP() → STUN + ICE candidates → extract IPv4 → ips[]',
395
+ summary: 'Tries to detect client IP via WebRTC; use as a hint and fall back to an IP API if needed.',
396
+ code: `const { ips, loading, error } = useWebRTCIP({ timeout: 3000 });`,
397
+ Live: DemoWebRTCIP,
398
+ },
399
+ {
400
+ name: 'useWasmCompute',
401
+ flow: 'Component → useWasmCompute({ wasmUrl }) → Web Worker → WASM → compute(input) → result',
402
+ summary: 'Runs WebAssembly in a worker; returns compute(input), result, loading, ready.',
403
+ code: `const { compute, result, ready } = useWasmCompute({ wasmUrl: '/add.wasm' });\ncompute(41); // → 42`,
404
+ Live: DemoWasmCompute,
405
+ },
406
+ {
407
+ name: 'useWorkerNotifications',
408
+ flow: 'Worker → useWorkerNotifications(worker) → message listener → runningTasks, completed/failed, history, throughput, progress',
409
+ summary: 'Listens to worker messages (task_start/task_end/task_fail/queue_size); tracks running tasks, counts, event history, avg duration, throughput/s, queue size; progress gives default view of all active worker data.',
410
+ code: `const stats = useWorkerNotifications(worker, { maxHistory: 100 });\n// stats.progress, stats.runningTasks, stats.throughputPerSecond, ...`,
411
+ Live: DemoWorkerNotifications,
412
+ },
413
+ ];
414
+
415
+ function App() {
416
+ return h('div', {},
417
+ HOOKS.map((hook) =>
418
+ h('section', { key: hook.name, class: 'hook-section' }, [
419
+ h('h2', {}, hook.name),
420
+ h('div', { class: 'flow' }, hook.flow),
421
+ h('p', { class: 'summary' }, hook.summary),
422
+ h('div', { class: 'cards' }, [
423
+ h('div', { class: 'card' }, [
424
+ h('div', { class: 'card-title' }, 'Example'),
425
+ h('pre', { class: 'card-code' }, hook.code),
426
+ ]),
427
+ h('div', { class: 'card' }, [
428
+ h('div', { class: 'card-title' }, 'Live'),
429
+ h('div', { class: 'card-live' }, h(hook.Live)),
430
+ ]),
431
+ ]),
432
+ ])
433
+ )
434
+ );
435
+ }
436
+
437
+ render(h(App), document.getElementById('root'));