preact-missing-hooks 4.6.0 → 4.8.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/dist/react.js CHANGED
@@ -2084,12 +2084,282 @@ function usePrefetch() {
2084
2084
  return { prefetch, isPrefetched };
2085
2085
  }
2086
2086
 
2087
+ /**
2088
+ * Polls an async function at a fixed interval until it returns { done: true, data? }.
2089
+ * Useful for waiting on a backend job, readiness checks, or until a condition is met.
2090
+ *
2091
+ * @param pollFn - Async function called each tick. Return { done: true, data? } to stop and set result.
2092
+ * @param options - intervalMs, immediate, enabled
2093
+ * @returns { data, done, error, pollCount, start, stop }
2094
+ *
2095
+ * @example
2096
+ * ```tsx
2097
+ * const { data, done, pollCount } = usePoll(
2098
+ * async () => {
2099
+ * const res = await fetch('/api/status');
2100
+ * const json = await res.json();
2101
+ * return json.ready ? { done: true, data: json } : { done: false };
2102
+ * },
2103
+ * { intervalMs: 500, immediate: true }
2104
+ * );
2105
+ * return done ? <div>Ready: {JSON.stringify(data)}</div> : <div>Polling… ({pollCount})</div>;
2106
+ * ```
2107
+ */
2108
+ function usePoll(pollFn, options = {}) {
2109
+ const { intervalMs = 1000, immediate = true, enabled = true } = options;
2110
+ const [data, setData] = react.useState(null);
2111
+ const [done, setDone] = react.useState(false);
2112
+ const [error, setError] = react.useState(null);
2113
+ const [pollCount, setPollCount] = react.useState(0);
2114
+ const [running, setRunning] = react.useState(false);
2115
+ const intervalRef = react.useRef(null);
2116
+ const pollFnRef = react.useRef(pollFn);
2117
+ pollFnRef.current = pollFn;
2118
+ const stop = react.useCallback(() => {
2119
+ if (intervalRef.current != null) {
2120
+ clearInterval(intervalRef.current);
2121
+ intervalRef.current = null;
2122
+ }
2123
+ setRunning(false);
2124
+ }, []);
2125
+ const tick = react.useCallback(() => __awaiter(this, void 0, void 0, function* () {
2126
+ try {
2127
+ setError(null);
2128
+ const result = yield pollFnRef.current();
2129
+ setPollCount((n) => n + 1);
2130
+ if (result.done) {
2131
+ stop();
2132
+ setDone(true);
2133
+ if (result.data !== undefined) {
2134
+ setData(result.data);
2135
+ }
2136
+ }
2137
+ }
2138
+ catch (e) {
2139
+ const err = e instanceof Error ? e : new Error(String(e));
2140
+ setError(err);
2141
+ stop();
2142
+ }
2143
+ }), [stop]);
2144
+ const start = react.useCallback(() => {
2145
+ if (!enabled || running)
2146
+ return;
2147
+ setRunning(true);
2148
+ if (immediate) {
2149
+ tick();
2150
+ }
2151
+ intervalRef.current = setInterval(tick, intervalMs);
2152
+ }, [enabled, immediate, intervalMs, running, tick]);
2153
+ react.useEffect(() => {
2154
+ if (enabled) {
2155
+ start();
2156
+ }
2157
+ return () => {
2158
+ stop();
2159
+ };
2160
+ }, [enabled]);
2161
+ return { data, done, error, pollCount, start, stop };
2162
+ }
2163
+
2164
+ const SSR_DEVICE_DATA = {
2165
+ userAgent: "",
2166
+ language: "en",
2167
+ languages: ["en"],
2168
+ platform: "",
2169
+ cookieEnabled: false,
2170
+ online: true,
2171
+ maxTouchPoints: 0,
2172
+ vendor: "",
2173
+ touch: false,
2174
+ screen: {
2175
+ width: 0,
2176
+ height: 0,
2177
+ availWidth: 0,
2178
+ availHeight: 0,
2179
+ colorDepth: 24,
2180
+ pixelRatio: 1,
2181
+ },
2182
+ viewport: { width: 0, height: 0 },
2183
+ reducedMotion: false,
2184
+ colorScheme: "no-preference",
2185
+ };
2186
+ function getColorScheme() {
2187
+ if (typeof window === "undefined" || !window.matchMedia) {
2188
+ return "no-preference";
2189
+ }
2190
+ if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
2191
+ return "dark";
2192
+ }
2193
+ if (window.matchMedia("(prefers-color-scheme: light)").matches) {
2194
+ return "light";
2195
+ }
2196
+ return "no-preference";
2197
+ }
2198
+ function getReducedMotion() {
2199
+ if (typeof window === "undefined" || !window.matchMedia) {
2200
+ return false;
2201
+ }
2202
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2203
+ }
2204
+ /** Reads synchronous device / browser data from Navigator, Screen, and matchMedia. */
2205
+ function getDeviceData() {
2206
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
2207
+ if (typeof navigator === "undefined") {
2208
+ return SSR_DEVICE_DATA;
2209
+ }
2210
+ const nav = navigator;
2211
+ const screen = typeof globalThis.screen !== "undefined" ? globalThis.screen : null;
2212
+ const win = typeof globalThis.window !== "undefined" ? globalThis.window : null;
2213
+ const data = {
2214
+ userAgent: (_a = nav.userAgent) !== null && _a !== void 0 ? _a : "",
2215
+ language: (_b = nav.language) !== null && _b !== void 0 ? _b : "",
2216
+ languages: nav.languages ? [...nav.languages] : [],
2217
+ platform: (_c = nav.platform) !== null && _c !== void 0 ? _c : "",
2218
+ cookieEnabled: Boolean(nav.cookieEnabled),
2219
+ online: Boolean(nav.onLine),
2220
+ maxTouchPoints: (_d = nav.maxTouchPoints) !== null && _d !== void 0 ? _d : 0,
2221
+ vendor: (_e = nav.vendor) !== null && _e !== void 0 ? _e : "",
2222
+ touch: ((_f = nav.maxTouchPoints) !== null && _f !== void 0 ? _f : 0) > 0,
2223
+ screen: {
2224
+ width: (_g = screen === null || screen === void 0 ? void 0 : screen.width) !== null && _g !== void 0 ? _g : 0,
2225
+ height: (_h = screen === null || screen === void 0 ? void 0 : screen.height) !== null && _h !== void 0 ? _h : 0,
2226
+ availWidth: (_j = screen === null || screen === void 0 ? void 0 : screen.availWidth) !== null && _j !== void 0 ? _j : 0,
2227
+ availHeight: (_k = screen === null || screen === void 0 ? void 0 : screen.availHeight) !== null && _k !== void 0 ? _k : 0,
2228
+ colorDepth: (_l = screen === null || screen === void 0 ? void 0 : screen.colorDepth) !== null && _l !== void 0 ? _l : 24,
2229
+ pixelRatio: (_m = win === null || win === void 0 ? void 0 : win.devicePixelRatio) !== null && _m !== void 0 ? _m : 1,
2230
+ },
2231
+ viewport: {
2232
+ width: (_o = win === null || win === void 0 ? void 0 : win.innerWidth) !== null && _o !== void 0 ? _o : 0,
2233
+ height: (_p = win === null || win === void 0 ? void 0 : win.innerHeight) !== null && _p !== void 0 ? _p : 0,
2234
+ },
2235
+ reducedMotion: getReducedMotion(),
2236
+ colorScheme: getColorScheme(),
2237
+ };
2238
+ if (typeof nav.hardwareConcurrency === "number") {
2239
+ data.hardwareConcurrency = nav.hardwareConcurrency;
2240
+ }
2241
+ if (typeof nav.deviceMemory === "number") {
2242
+ data.deviceMemory = nav.deviceMemory;
2243
+ }
2244
+ const uaData = nav.userAgentData;
2245
+ if (uaData) {
2246
+ data.userAgentData = {
2247
+ mobile: Boolean(uaData.mobile),
2248
+ platform: (_q = uaData.platform) !== null && _q !== void 0 ? _q : "",
2249
+ brands: uaData.brands ? [...uaData.brands] : [],
2250
+ };
2251
+ }
2252
+ return data;
2253
+ }
2254
+ function readBattery() {
2255
+ return __awaiter(this, void 0, void 0, function* () {
2256
+ if (typeof navigator === "undefined")
2257
+ return undefined;
2258
+ const getBattery = navigator.getBattery;
2259
+ if (!getBattery)
2260
+ return undefined;
2261
+ try {
2262
+ const battery = yield getBattery.call(navigator);
2263
+ return { charging: battery.charging, level: battery.level };
2264
+ }
2265
+ catch (_a) {
2266
+ return undefined;
2267
+ }
2268
+ });
2269
+ }
2270
+ /**
2271
+ * Extracts device and browser data from native Navigator, Screen, window, and
2272
+ * matchMedia APIs. Updates on resize, orientation, online/offline, and
2273
+ * prefers-color-scheme / prefers-reduced-motion changes. Optionally polls the
2274
+ * Battery Status API when available.
2275
+ *
2276
+ * @param options - `includeBattery` (default true), `batteryPollIntervalMs` (default 60000)
2277
+ * @returns Current {@link DeviceData} snapshot
2278
+ *
2279
+ * @example
2280
+ * ```tsx
2281
+ * function DevicePanel() {
2282
+ * const device = useDeviceData();
2283
+ * return (
2284
+ * <dl>
2285
+ * <dt>Language</dt><dd>{device.language}</dd>
2286
+ * <dt>CPUs</dt><dd>{device.hardwareConcurrency ?? '—'}</dd>
2287
+ * <dt>Viewport</dt><dd>{device.viewport.width}×{device.viewport.height}</dd>
2288
+ * <dt>Theme</dt><dd>{device.colorScheme}</dd>
2289
+ * </dl>
2290
+ * );
2291
+ * }
2292
+ * ```
2293
+ */
2294
+ function useDeviceData(options = {}) {
2295
+ const { includeBattery = true, batteryPollIntervalMs = 60000 } = options;
2296
+ const [data, setData] = react.useState(() => getDeviceData());
2297
+ const refresh = react.useCallback(() => {
2298
+ setData((prev) => {
2299
+ const next = getDeviceData();
2300
+ return prev.battery ? Object.assign(Object.assign({}, next), { battery: prev.battery }) : next;
2301
+ });
2302
+ }, []);
2303
+ react.useEffect(() => {
2304
+ var _a, _b, _c, _d, _e, _f;
2305
+ if (typeof window === "undefined")
2306
+ return;
2307
+ const onResize = () => refresh();
2308
+ window.addEventListener("resize", onResize);
2309
+ window.addEventListener("orientationchange", onResize);
2310
+ window.addEventListener("online", refresh);
2311
+ window.addEventListener("offline", refresh);
2312
+ const reducedMotionMq = (_a = window.matchMedia) === null || _a === void 0 ? void 0 : _a.call(window, "(prefers-reduced-motion: reduce)");
2313
+ const darkMq = (_b = window.matchMedia) === null || _b === void 0 ? void 0 : _b.call(window, "(prefers-color-scheme: dark)");
2314
+ const lightMq = (_c = window.matchMedia) === null || _c === void 0 ? void 0 : _c.call(window, "(prefers-color-scheme: light)");
2315
+ const onMediaChange = () => refresh();
2316
+ (_d = reducedMotionMq === null || reducedMotionMq === void 0 ? void 0 : reducedMotionMq.addEventListener) === null || _d === void 0 ? void 0 : _d.call(reducedMotionMq, "change", onMediaChange);
2317
+ (_e = darkMq === null || darkMq === void 0 ? void 0 : darkMq.addEventListener) === null || _e === void 0 ? void 0 : _e.call(darkMq, "change", onMediaChange);
2318
+ (_f = lightMq === null || lightMq === void 0 ? void 0 : lightMq.addEventListener) === null || _f === void 0 ? void 0 : _f.call(lightMq, "change", onMediaChange);
2319
+ return () => {
2320
+ var _a, _b, _c;
2321
+ window.removeEventListener("resize", onResize);
2322
+ window.removeEventListener("orientationchange", onResize);
2323
+ window.removeEventListener("online", refresh);
2324
+ window.removeEventListener("offline", refresh);
2325
+ (_a = reducedMotionMq === null || reducedMotionMq === void 0 ? void 0 : reducedMotionMq.removeEventListener) === null || _a === void 0 ? void 0 : _a.call(reducedMotionMq, "change", onMediaChange);
2326
+ (_b = darkMq === null || darkMq === void 0 ? void 0 : darkMq.removeEventListener) === null || _b === void 0 ? void 0 : _b.call(darkMq, "change", onMediaChange);
2327
+ (_c = lightMq === null || lightMq === void 0 ? void 0 : lightMq.removeEventListener) === null || _c === void 0 ? void 0 : _c.call(lightMq, "change", onMediaChange);
2328
+ };
2329
+ }, [refresh]);
2330
+ react.useEffect(() => {
2331
+ if (!includeBattery || typeof navigator === "undefined")
2332
+ return;
2333
+ let cancelled = false;
2334
+ let intervalId;
2335
+ const updateBattery = () => __awaiter(this, void 0, void 0, function* () {
2336
+ const battery = yield readBattery();
2337
+ if (cancelled || battery === undefined)
2338
+ return;
2339
+ setData((prev) => (Object.assign(Object.assign({}, prev), { battery })));
2340
+ });
2341
+ void updateBattery();
2342
+ if (batteryPollIntervalMs > 0) {
2343
+ intervalId = setInterval(() => void updateBattery(), batteryPollIntervalMs);
2344
+ }
2345
+ return () => {
2346
+ cancelled = true;
2347
+ if (intervalId !== undefined)
2348
+ clearInterval(intervalId);
2349
+ };
2350
+ }, [includeBattery, batteryPollIntervalMs]);
2351
+ return data;
2352
+ }
2353
+
2354
+ exports.getDeviceData = getDeviceData;
2087
2355
  exports.useClipboard = useClipboard;
2356
+ exports.useDeviceData = useDeviceData;
2088
2357
  exports.useEventBus = useEventBus;
2089
2358
  exports.useIndexedDB = useIndexedDB;
2090
2359
  exports.useLLMMetadata = useLLMMetadata;
2091
2360
  exports.useMutationObserver = useMutationObserver;
2092
2361
  exports.useNetworkState = useNetworkState;
2362
+ exports.usePoll = usePoll;
2093
2363
  exports.usePreferredTheme = usePreferredTheme;
2094
2364
  exports.usePrefetch = usePrefetch;
2095
2365
  exports.useRBAC = useRBAC;
@@ -0,0 +1,82 @@
1
+ /** Parsed Client Hints from `navigator.userAgentData` when available */
2
+ export interface UserAgentDataInfo {
3
+ mobile: boolean;
4
+ platform: string;
5
+ brands: ReadonlyArray<{
6
+ brand: string;
7
+ version: string;
8
+ }>;
9
+ }
10
+ /** Screen and display metrics from `screen` and `window` */
11
+ export interface DeviceScreenInfo {
12
+ width: number;
13
+ height: number;
14
+ availWidth: number;
15
+ availHeight: number;
16
+ colorDepth: number;
17
+ pixelRatio: number;
18
+ }
19
+ /** Viewport size (`window.innerWidth` / `innerHeight`) */
20
+ export interface DeviceViewportInfo {
21
+ width: number;
22
+ height: number;
23
+ }
24
+ /** Battery status from the Battery Status API when available */
25
+ export interface DeviceBatteryInfo {
26
+ charging: boolean;
27
+ level: number;
28
+ }
29
+ /** Snapshot of device / browser data from native Navigator and related APIs */
30
+ export interface DeviceData {
31
+ userAgent: string;
32
+ language: string;
33
+ languages: readonly string[];
34
+ platform: string;
35
+ cookieEnabled: boolean;
36
+ online: boolean;
37
+ hardwareConcurrency?: number;
38
+ /** Approximate device RAM in GB (Chrome / some browsers only) */
39
+ deviceMemory?: number;
40
+ maxTouchPoints: number;
41
+ vendor: string;
42
+ touch: boolean;
43
+ screen: DeviceScreenInfo;
44
+ viewport: DeviceViewportInfo;
45
+ userAgentData?: UserAgentDataInfo;
46
+ reducedMotion: boolean;
47
+ colorScheme: "light" | "dark" | "no-preference";
48
+ battery?: DeviceBatteryInfo;
49
+ }
50
+ export interface UseDeviceDataOptions {
51
+ /** Fetch battery info when the Battery Status API exists (default: true) */
52
+ includeBattery?: boolean;
53
+ /** Battery refresh interval in ms (default: 60000) */
54
+ batteryPollIntervalMs?: number;
55
+ }
56
+ /** Reads synchronous device / browser data from Navigator, Screen, and matchMedia. */
57
+ export declare function getDeviceData(): DeviceData;
58
+ /**
59
+ * Extracts device and browser data from native Navigator, Screen, window, and
60
+ * matchMedia APIs. Updates on resize, orientation, online/offline, and
61
+ * prefers-color-scheme / prefers-reduced-motion changes. Optionally polls the
62
+ * Battery Status API when available.
63
+ *
64
+ * @param options - `includeBattery` (default true), `batteryPollIntervalMs` (default 60000)
65
+ * @returns Current {@link DeviceData} snapshot
66
+ *
67
+ * @example
68
+ * ```tsx
69
+ * function DevicePanel() {
70
+ * const device = useDeviceData();
71
+ * return (
72
+ * <dl>
73
+ * <dt>Language</dt><dd>{device.language}</dd>
74
+ * <dt>CPUs</dt><dd>{device.hardwareConcurrency ?? '—'}</dd>
75
+ * <dt>Viewport</dt><dd>{device.viewport.width}×{device.viewport.height}</dd>
76
+ * <dt>Theme</dt><dd>{device.colorScheme}</dd>
77
+ * </dl>
78
+ * );
79
+ * }
80
+ * ```
81
+ */
82
+ export declare function useDeviceData(options?: UseDeviceDataOptions): DeviceData;
@@ -0,0 +1,47 @@
1
+ export interface UsePollOptions {
2
+ /** Polling interval in milliseconds. Default: 1000 */
3
+ intervalMs?: number;
4
+ /** Run the poll function immediately when the hook mounts. Default: true */
5
+ immediate?: boolean;
6
+ /** When false, do not start or continue polling. Default: true */
7
+ enabled?: boolean;
8
+ }
9
+ export interface UsePollResult<T> {
10
+ /** Last resolved data when poll returned done: true */
11
+ data: T | null;
12
+ /** True once the poll function returned { done: true } */
13
+ done: boolean;
14
+ /** Error from the last failed poll call */
15
+ error: Error | null;
16
+ /** Number of times the poll function has been invoked */
17
+ pollCount: number;
18
+ /** Manually start polling (e.g. after reset). Only has effect when not already polling. */
19
+ start: () => void;
20
+ /** Stop polling. */
21
+ stop: () => void;
22
+ }
23
+ /**
24
+ * Polls an async function at a fixed interval until it returns { done: true, data? }.
25
+ * Useful for waiting on a backend job, readiness checks, or until a condition is met.
26
+ *
27
+ * @param pollFn - Async function called each tick. Return { done: true, data? } to stop and set result.
28
+ * @param options - intervalMs, immediate, enabled
29
+ * @returns { data, done, error, pollCount, start, stop }
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * const { data, done, pollCount } = usePoll(
34
+ * async () => {
35
+ * const res = await fetch('/api/status');
36
+ * const json = await res.json();
37
+ * return json.ready ? { done: true, data: json } : { done: false };
38
+ * },
39
+ * { intervalMs: 500, immediate: true }
40
+ * );
41
+ * return done ? <div>Ready: {JSON.stringify(data)}</div> : <div>Polling… ({pollCount})</div>;
42
+ * ```
43
+ */
44
+ export declare function usePoll<T>(pollFn: () => Promise<{
45
+ done: boolean;
46
+ data?: T;
47
+ }>, options?: UsePollOptions): UsePollResult<T>;
package/docs/README.md CHANGED
@@ -41,7 +41,9 @@ Open `docs/index.html` in a browser that supports ES modules and import maps. Th
41
41
  | **useWrappedChildren** | Children buttons get injected styles. |
42
42
  | **usePreferredTheme** | Shows light / dark / no-preference from system. |
43
43
  | **useNetworkState** | Online/offline and connection type. |
44
+ | **useDeviceData** | Live device/browser snapshot: language, CPUs, memory, viewport, screen, touch, color scheme, reduced motion, Client Hints. |
44
45
  | **usePrefetch** | Hover or click to prefetch a URL (document or fetch); see prefetched status. |
46
+ | **usePoll** | Poll until done (3 ticks); see poll count and result. Stop button to cancel. |
45
47
  | **useClipboard** | Copy and paste; see “Copied!” and pasted text. |
46
48
  | **useRageClick** | Click the area 3+ times quickly; rage click count. |
47
49
  | **useThreadedWorker** | Run a task; see loading and result. |
package/docs/main.js CHANGED
@@ -22,6 +22,8 @@ const {
22
22
  useRefPrint,
23
23
  useRBAC,
24
24
  usePrefetch,
25
+ usePoll,
26
+ useDeviceData,
25
27
  } = await import(
26
28
  isLocal ? '../dist/index.module.js' : 'https://unpkg.com/preact-missing-hooks/dist/index.module.js'
27
29
  );
@@ -85,6 +87,33 @@ function DemoNetworkState() {
85
87
  );
86
88
  }
87
89
 
90
+ function DemoDeviceData() {
91
+ const device = useDeviceData({ includeBattery: false });
92
+ const rows = [
93
+ ['Language', device.language],
94
+ ['Platform', device.platform || device.userAgentData?.platform || '—'],
95
+ ['CPUs', device.hardwareConcurrency != null ? String(device.hardwareConcurrency) : '—'],
96
+ ['Memory (GB)', device.deviceMemory != null ? String(device.deviceMemory) : '—'],
97
+ ['Viewport', device.viewport.width + '×' + device.viewport.height],
98
+ ['Screen', device.screen.width + '×' + device.screen.height],
99
+ ['Touch', device.touch ? 'yes' : 'no'],
100
+ ['Color scheme', device.colorScheme],
101
+ ['Reduced motion', device.reducedMotion ? 'yes' : 'no'],
102
+ ['Online', device.online ? 'yes' : 'no'],
103
+ ];
104
+ if (device.userAgentData?.mobile) {
105
+ rows.push(['Mobile (UA-CH)', 'yes']);
106
+ }
107
+ return h('div', { class: 'status', style: { fontSize: '0.85rem' } },
108
+ rows.map(([label, value]) =>
109
+ h('div', { key: label, style: { marginBottom: '0.25rem' } },
110
+ h('strong', {}, label + ': '),
111
+ value
112
+ )
113
+ )
114
+ );
115
+ }
116
+
88
117
  function DemoPrefetch() {
89
118
  const { prefetch, isPrefetched } = usePrefetch();
90
119
  const [lastUrl, setLastUrl] = useState('');
@@ -111,6 +140,29 @@ function DemoPrefetch() {
111
140
  );
112
141
  }
113
142
 
143
+ function DemoPoll() {
144
+ const countRef = { current: 0 };
145
+ const { data, done, error, pollCount, start, stop } = usePoll(
146
+ async () => {
147
+ countRef.current += 1;
148
+ if (countRef.current >= 3) return { done: true, data: { message: 'Ready after ' + countRef.current + ' polls' } };
149
+ return { done: false };
150
+ },
151
+ { intervalMs: 700, immediate: true }
152
+ );
153
+ return h('div', {},
154
+ h('div', { style: { marginBottom: '0.5rem', fontSize: '0.85rem' } }, [
155
+ h('button', { onClick: start }, 'Start'),
156
+ ' ',
157
+ h('button', { onClick: stop }, 'Stop'),
158
+ ]),
159
+ error ? h('span', { class: 'badge', style: { background: 'var(--red)', color: '#fff' } }, error.message) : null,
160
+ done
161
+ ? h('span', { class: 'badge green', style: { marginLeft: '0.35rem' } }, data?.message ?? 'Done')
162
+ : h('span', { class: 'status' }, 'Polling… (' + pollCount + ' calls)')
163
+ );
164
+ }
165
+
114
166
  function DemoClipboard() {
115
167
  const { copy, paste, copied, error } = useClipboard();
116
168
  const [pasted, setPasted] = useState('');
@@ -538,6 +590,13 @@ const HOOKS = [
538
590
  code: `const state = useNetworkState();\n// state.online, state.effectiveType, ...`,
539
591
  Live: DemoNetworkState,
540
592
  },
593
+ {
594
+ name: 'useDeviceData',
595
+ flow: 'Component → useDeviceData() → Navigator + Screen + matchMedia (+ optional Battery API)',
596
+ summary: 'Extracts device/browser data: language, platform, CPUs, memory, screen, viewport, touch, color scheme, reduced motion, Client Hints, and optional battery.',
597
+ code: `const device = useDeviceData();\n// device.language, device.hardwareConcurrency, device.viewport, device.colorScheme`,
598
+ Live: DemoDeviceData,
599
+ },
541
600
  {
542
601
  name: 'usePrefetch',
543
602
  flow: 'Component → usePrefetch() → prefetch(url, options?) → link rel=prefetch or fetch()',
@@ -545,6 +604,13 @@ const HOOKS = [
545
604
  code: `const { prefetch, isPrefetched } = usePrefetch();\n<a onMouseEnter={() => prefetch(href)} href={href}>Link</a>\n// or prefetch(url, { as: 'fetch' }) for API`,
546
605
  Live: DemoPrefetch,
547
606
  },
607
+ {
608
+ name: 'usePoll',
609
+ flow: 'Component → usePoll(pollFn, { intervalMs, immediate }) → poll until done: true → data, done, pollCount',
610
+ summary: 'Polls an async function at a fixed interval until it returns { done: true, data? }. Stops on error. Good for readiness checks or waiting on a backend job.',
611
+ code: `const { data, done, error, pollCount, stop } = usePoll(\n async () => (await fetch('/api/status')).ok ? { done: true, data } : { done: false },\n { intervalMs: 1000, immediate: true }\n);`,
612
+ Live: DemoPoll,
613
+ },
548
614
  {
549
615
  name: 'useClipboard',
550
616
  flow: 'Component → useClipboard() → copy(text) / paste() → Clipboard API',