use-poller 0.0.1 → 0.1.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
@@ -1,20 +1,138 @@
1
1
  # use-poller
2
2
 
3
- Initial placeholder release for claiming the `use-poller` package name on npm.
3
+ Polling done right for the cases where cache-oriented query tools are not the right fit.
4
4
 
5
- ## Status
5
+ `use-poller` is a tiny React hook for robust async polling with:
6
6
 
7
- `0.0.1` is intentionally minimal and does not provide the final polling hook API yet.
7
+ - no request overlap,
8
+ - abort on stop/unmount,
9
+ - optional pause when tab is hidden/offline,
10
+ - optional exponential backoff and stop-on-error,
11
+ - stable control API (`start`, `stop`, `refresh`).
8
12
 
9
- ## Why this exists
13
+ ## Behavior guarantees
10
14
 
11
- This release reserves the package name while the full implementation is being completed.
15
+ - Next tick is scheduled only after the previous callback settles (no overlap).
16
+ - Active request is aborted on `stop()` and unmount.
17
+ - Polling can pause when the page is hidden or offline and resume on return.
18
+ - `refresh()` runs now and re-aligns the next scheduled tick.
12
19
 
13
- ## Roadmap
20
+ ## Install
14
21
 
15
- - Deliver production-ready `usePoller` hook
16
- - Add full test coverage for timing and lifecycle edge cases
17
- - Publish full docs and examples
22
+ ```bash
23
+ npm i use-poller
24
+ ```
25
+
26
+ ## Quick start
27
+
28
+ ```ts
29
+ import { usePoller } from "use-poller";
30
+
31
+ const { data, error, isPolling, stop } = usePoller(
32
+ async ({ signal }) => {
33
+ const response = await fetch("/api/export/status", { signal });
34
+ const payload = await response.json();
35
+ if (payload.status === "done") {
36
+ stop();
37
+ }
38
+ return payload;
39
+ },
40
+ { interval: 2000 },
41
+ );
42
+ ```
43
+
44
+ ## API
45
+
46
+ ```ts
47
+ function usePoller<T>(
48
+ callback: PollerCallback<T>,
49
+ options?: UsePollerOptions<T>,
50
+ ): UsePollerResult<T>;
51
+ ```
52
+
53
+ ### `PollerCallback<T>`
54
+
55
+ ```ts
56
+ type PollerCallback<T> = (ctx: {
57
+ signal: AbortSignal;
58
+ attempt: number;
59
+ }) => T | Promise<T>;
60
+ ```
61
+
62
+ Pass `signal` to your async work (for example `fetch(url, { signal })`) so the hook can cancel in-flight work safely during `stop`, unmount, or pause transitions.
63
+
64
+ ### `UsePollerOptions<T>`
65
+
66
+ - `interval?: number | ((ctx: { data: T | undefined; attempt: number }) => number)` (default `5000`)
67
+ - `enabled?: boolean` (default `true`)
68
+ - `immediate?: boolean` (default `true`)
69
+ - `pauseOnHidden?: boolean` (default `true`)
70
+ - `pauseOnOffline?: boolean` (default `false`)
71
+ - `backoff?: { factor: number; max: number } | false` (default `{ factor: 2, max: 30000 }`)
72
+ - `stopOnError?: boolean | number` (default `false`, `true` means stop after first error)
73
+ - `onSuccess?: (data: T) => void`
74
+ - `onError?: (error: unknown) => void`
75
+
76
+ ### `UsePollerResult<T>`
77
+
78
+ - `data: T | undefined`
79
+ - `error: unknown | null`
80
+ - `isPolling: boolean`
81
+ - `isFetching: boolean`
82
+ - `lastUpdated: number | null`
83
+ - `start: () => void`
84
+ - `stop: () => void`
85
+ - `refresh: () => Promise<T | undefined>`
86
+
87
+ ## Recipes
88
+
89
+ ### Health check with backoff
90
+
91
+ ```ts
92
+ usePoller(({ signal }) => fetch("/health", { signal }).then((r) => r.json()), {
93
+ interval: 5000,
94
+ backoff: { factor: 2, max: 60000 },
95
+ stopOnError: 5,
96
+ });
97
+ ```
98
+
99
+ ### Dynamic interval
100
+
101
+ ```ts
102
+ usePoller(fetchNotifications, {
103
+ interval: ({ data }) => (data?.unread ? 3000 : 15000),
104
+ });
105
+ ```
106
+
107
+ ### Manual refresh
108
+
109
+ ```ts
110
+ const poller = usePoller(fetchStatus, { interval: 5000 });
111
+
112
+ await poller.refresh(); // run immediately and reset next tick countdown
113
+ ```
114
+
115
+ ### Pause when offline
116
+
117
+ ```ts
118
+ usePoller(fetchStatus, {
119
+ interval: 3000,
120
+ pauseOnOffline: true,
121
+ });
122
+ ```
123
+
124
+ ## `use-poller` vs TanStack Query
125
+
126
+ - Use TanStack Query when data lifecycle is cache-centric and query-driven.
127
+ - Use `use-poller` when you need imperative polling loops or side effects outside query cache semantics.
128
+
129
+ They complement each other.
130
+
131
+ ## Compatibility
132
+
133
+ - React `>=18`
134
+ - SSR-safe (no `window`/`document` access during render)
135
+ - StrictMode-safe cleanup
18
136
 
19
137
  ## License
20
138
 
package/dist/index.cjs CHANGED
@@ -23,9 +23,316 @@ __export(index_exports, {
23
23
  usePoller: () => usePoller
24
24
  });
25
25
  module.exports = __toCommonJS(index_exports);
26
- function usePoller() {
27
- throw new Error(
28
- "use-poller@0.0.1 is a placeholder release. Full implementation is coming next."
26
+
27
+ // src/usePoller.ts
28
+ var import_react = require("react");
29
+ var DEFAULT_INTERVAL = 5e3;
30
+ var DEFAULT_BACKOFF = { factor: 2, max: 3e4 };
31
+ var isBrowser = () => typeof window !== "undefined" && typeof document !== "undefined";
32
+ var toPositiveDelay = (value) => {
33
+ if (!Number.isFinite(value) || value < 0) {
34
+ return 0;
35
+ }
36
+ return value;
37
+ };
38
+ var resolveInterval = (interval, data, attempt) => {
39
+ const raw = typeof interval === "function" ? interval({ data, attempt }) : interval;
40
+ return toPositiveDelay(raw);
41
+ };
42
+ var resolveStopOnError = (value) => {
43
+ if (value === true) {
44
+ return 1;
45
+ }
46
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
47
+ return Math.floor(value);
48
+ }
49
+ return 0;
50
+ };
51
+ function usePoller(callback, options = {}) {
52
+ const {
53
+ interval = DEFAULT_INTERVAL,
54
+ enabled = true,
55
+ immediate = true,
56
+ pauseOnHidden = true,
57
+ pauseOnOffline = false,
58
+ backoff = DEFAULT_BACKOFF,
59
+ stopOnError = false,
60
+ onSuccess,
61
+ onError
62
+ } = options;
63
+ const [data, setData] = (0, import_react.useState)(void 0);
64
+ const [error, setError] = (0, import_react.useState)(null);
65
+ const [isPolling, setIsPolling] = (0, import_react.useState)(enabled);
66
+ const [isFetching, setIsFetching] = (0, import_react.useState)(false);
67
+ const [lastUpdated, setLastUpdated] = (0, import_react.useState)(null);
68
+ const callbackRef = (0, import_react.useRef)(callback);
69
+ const onSuccessRef = (0, import_react.useRef)(onSuccess);
70
+ const onErrorRef = (0, import_react.useRef)(onError);
71
+ const intervalRef = (0, import_react.useRef)(interval);
72
+ const backoffRef = (0, import_react.useRef)(backoff);
73
+ const stopOnErrorRef = (0, import_react.useRef)(resolveStopOnError(stopOnError));
74
+ const pauseOnHiddenRef = (0, import_react.useRef)(pauseOnHidden);
75
+ const pauseOnOfflineRef = (0, import_react.useRef)(pauseOnOffline);
76
+ const enabledRef = (0, import_react.useRef)(enabled);
77
+ const dataRef = (0, import_react.useRef)(void 0);
78
+ const attemptRef = (0, import_react.useRef)(0);
79
+ const errorStreakRef = (0, import_react.useRef)(0);
80
+ const mountedRef = (0, import_react.useRef)(false);
81
+ const isFetchingRef = (0, import_react.useRef)(false);
82
+ const pollingRef = (0, import_react.useRef)(enabled);
83
+ const timerRef = (0, import_react.useRef)(null);
84
+ const activeAbortControllerRef = (0, import_react.useRef)(null);
85
+ callbackRef.current = callback;
86
+ onSuccessRef.current = onSuccess;
87
+ onErrorRef.current = onError;
88
+ intervalRef.current = interval;
89
+ backoffRef.current = backoff;
90
+ stopOnErrorRef.current = resolveStopOnError(stopOnError);
91
+ pauseOnHiddenRef.current = pauseOnHidden;
92
+ pauseOnOfflineRef.current = pauseOnOffline;
93
+ enabledRef.current = enabled;
94
+ const clearTimer = (0, import_react.useCallback)(() => {
95
+ if (timerRef.current !== null) {
96
+ clearTimeout(timerRef.current);
97
+ timerRef.current = null;
98
+ }
99
+ }, []);
100
+ const abortActiveRequest = (0, import_react.useCallback)(() => {
101
+ if (activeAbortControllerRef.current !== null) {
102
+ activeAbortControllerRef.current.abort();
103
+ activeAbortControllerRef.current = null;
104
+ isFetchingRef.current = false;
105
+ if (mountedRef.current) {
106
+ setIsFetching(false);
107
+ }
108
+ }
109
+ }, []);
110
+ const shouldPause = (0, import_react.useCallback)(() => {
111
+ if (!isBrowser()) {
112
+ return false;
113
+ }
114
+ if (pauseOnHiddenRef.current && document.visibilityState === "hidden") {
115
+ return true;
116
+ }
117
+ if (pauseOnOfflineRef.current && typeof navigator.onLine === "boolean" && !navigator.onLine) {
118
+ return true;
119
+ }
120
+ return false;
121
+ }, []);
122
+ const computeNextDelay = (0, import_react.useCallback)(() => {
123
+ const baseDelay = resolveInterval(
124
+ intervalRef.current,
125
+ dataRef.current,
126
+ attemptRef.current
127
+ );
128
+ const currentBackoff = backoffRef.current;
129
+ if (currentBackoff === false || errorStreakRef.current === 0) {
130
+ return baseDelay;
131
+ }
132
+ const backoffFactor = Math.max(currentBackoff.factor, 1);
133
+ const multipliedDelay = baseDelay * backoffFactor ** errorStreakRef.current;
134
+ return Math.min(multipliedDelay, currentBackoff.max);
135
+ }, []);
136
+ const setPollingState = (0, import_react.useCallback)((nextValue) => {
137
+ pollingRef.current = nextValue;
138
+ setIsPolling(nextValue);
139
+ }, []);
140
+ const tickRef = (0, import_react.useRef)(void 0);
141
+ const maybeResumeTick = (0, import_react.useCallback)(() => {
142
+ if (!pollingRef.current) {
143
+ return;
144
+ }
145
+ if (shouldPause()) {
146
+ return;
147
+ }
148
+ if (isFetchingRef.current || activeAbortControllerRef.current !== null) {
149
+ return;
150
+ }
151
+ if (timerRef.current !== null) {
152
+ return;
153
+ }
154
+ void tickRef.current?.();
155
+ }, [shouldPause]);
156
+ const scheduleNext = (0, import_react.useCallback)(
157
+ (forcedDelay) => {
158
+ clearTimer();
159
+ if (!mountedRef.current || !pollingRef.current) {
160
+ return;
161
+ }
162
+ if (shouldPause()) {
163
+ return;
164
+ }
165
+ const delay = forcedDelay ?? computeNextDelay();
166
+ timerRef.current = setTimeout(() => {
167
+ void tickRef.current?.();
168
+ }, delay);
169
+ },
170
+ [clearTimer, computeNextDelay, shouldPause]
171
+ );
172
+ const tick = (0, import_react.useCallback)(async () => {
173
+ if (!mountedRef.current || !pollingRef.current || shouldPause()) {
174
+ return dataRef.current;
175
+ }
176
+ clearTimer();
177
+ abortActiveRequest();
178
+ const controller = new AbortController();
179
+ activeAbortControllerRef.current = controller;
180
+ isFetchingRef.current = true;
181
+ setIsFetching(true);
182
+ attemptRef.current += 1;
183
+ try {
184
+ const result = await callbackRef.current({
185
+ signal: controller.signal,
186
+ attempt: attemptRef.current
187
+ });
188
+ if (!mountedRef.current || controller.signal.aborted) {
189
+ return dataRef.current;
190
+ }
191
+ errorStreakRef.current = 0;
192
+ setError(null);
193
+ setData(result);
194
+ dataRef.current = result;
195
+ setLastUpdated(Date.now());
196
+ onSuccessRef.current?.(result);
197
+ scheduleNext();
198
+ return result;
199
+ } catch (caught) {
200
+ if (!mountedRef.current || controller.signal.aborted) {
201
+ return dataRef.current;
202
+ }
203
+ errorStreakRef.current += 1;
204
+ setError(caught);
205
+ onErrorRef.current?.(caught);
206
+ const maxErrors = stopOnErrorRef.current;
207
+ if (maxErrors > 0 && errorStreakRef.current >= maxErrors) {
208
+ setPollingState(false);
209
+ clearTimer();
210
+ return dataRef.current;
211
+ }
212
+ scheduleNext();
213
+ return dataRef.current;
214
+ } finally {
215
+ if (activeAbortControllerRef.current === controller) {
216
+ activeAbortControllerRef.current = null;
217
+ }
218
+ isFetchingRef.current = false;
219
+ if (mountedRef.current) {
220
+ setIsFetching(false);
221
+ }
222
+ }
223
+ }, [
224
+ abortActiveRequest,
225
+ clearTimer,
226
+ scheduleNext,
227
+ setPollingState,
228
+ shouldPause
229
+ ]);
230
+ tickRef.current = tick;
231
+ const stop = (0, import_react.useCallback)(() => {
232
+ setPollingState(false);
233
+ clearTimer();
234
+ abortActiveRequest();
235
+ }, [abortActiveRequest, clearTimer, setPollingState]);
236
+ const start = (0, import_react.useCallback)(() => {
237
+ if (!enabledRef.current) {
238
+ return;
239
+ }
240
+ setPollingState(true);
241
+ maybeResumeTick();
242
+ }, [maybeResumeTick, setPollingState]);
243
+ const refresh = (0, import_react.useCallback)(async () => {
244
+ if (!enabledRef.current || !pollingRef.current) {
245
+ return dataRef.current;
246
+ }
247
+ clearTimer();
248
+ const result = await tickRef.current?.();
249
+ clearTimer();
250
+ scheduleNext();
251
+ return result ?? dataRef.current;
252
+ }, [clearTimer, scheduleNext]);
253
+ (0, import_react.useEffect)(() => {
254
+ mountedRef.current = true;
255
+ pollingRef.current = enabled;
256
+ setIsPolling(enabled);
257
+ if (enabled && immediate && !shouldPause()) {
258
+ void tickRef.current?.();
259
+ } else if (enabled && !immediate) {
260
+ scheduleNext(resolveInterval(intervalRef.current, dataRef.current, attemptRef.current));
261
+ }
262
+ return () => {
263
+ mountedRef.current = false;
264
+ clearTimer();
265
+ abortActiveRequest();
266
+ };
267
+ }, []);
268
+ (0, import_react.useEffect)(() => {
269
+ if (enabled) {
270
+ if (!pollingRef.current) {
271
+ setPollingState(true);
272
+ if (shouldPause()) {
273
+ return;
274
+ }
275
+ if (immediate) {
276
+ void tickRef.current?.();
277
+ } else {
278
+ scheduleNext(resolveInterval(intervalRef.current, dataRef.current, attemptRef.current));
279
+ }
280
+ }
281
+ return;
282
+ }
283
+ stop();
284
+ }, [enabled, immediate, scheduleNext, setPollingState, shouldPause, stop]);
285
+ (0, import_react.useEffect)(() => {
286
+ if (!isBrowser()) {
287
+ return;
288
+ }
289
+ const onVisibilityChange = () => {
290
+ if (!pauseOnHiddenRef.current) {
291
+ return;
292
+ }
293
+ if (document.visibilityState === "hidden") {
294
+ clearTimer();
295
+ return;
296
+ }
297
+ if (document.visibilityState === "visible") {
298
+ maybeResumeTick();
299
+ }
300
+ };
301
+ document.addEventListener("visibilitychange", onVisibilityChange);
302
+ return () => {
303
+ document.removeEventListener("visibilitychange", onVisibilityChange);
304
+ };
305
+ }, [clearTimer, maybeResumeTick]);
306
+ (0, import_react.useEffect)(() => {
307
+ if (!isBrowser() || !pauseOnOfflineRef.current) {
308
+ return;
309
+ }
310
+ const onOffline = () => {
311
+ clearTimer();
312
+ abortActiveRequest();
313
+ };
314
+ const onOnline = () => {
315
+ maybeResumeTick();
316
+ };
317
+ window.addEventListener("offline", onOffline);
318
+ window.addEventListener("online", onOnline);
319
+ return () => {
320
+ window.removeEventListener("offline", onOffline);
321
+ window.removeEventListener("online", onOnline);
322
+ };
323
+ }, [abortActiveRequest, clearTimer, maybeResumeTick]);
324
+ return (0, import_react.useMemo)(
325
+ () => ({
326
+ data,
327
+ error,
328
+ isPolling,
329
+ isFetching,
330
+ lastUpdated,
331
+ start,
332
+ stop,
333
+ refresh
334
+ }),
335
+ [data, error, isPolling, isFetching, lastUpdated, start, stop, refresh]
29
336
  );
30
337
  }
31
338
  // Annotate the CommonJS export names for ESM import in node:
package/dist/index.d.cts CHANGED
@@ -1,7 +1,39 @@
1
- /**
2
- * Placeholder release to claim the package name on npm.
3
- * Full hook API is planned for upcoming versions.
4
- */
5
- declare function usePoller(): never;
1
+ interface PollerContext {
2
+ signal: AbortSignal;
3
+ attempt: number;
4
+ }
5
+ type PollerCallback<T> = (ctx: PollerContext) => T | Promise<T>;
6
+ type PollerIntervalContext<T> = {
7
+ data: T | undefined;
8
+ attempt: number;
9
+ };
10
+ type PollerInterval<T> = number | ((ctx: PollerIntervalContext<T>) => number);
11
+ interface PollerBackoff {
12
+ factor: number;
13
+ max: number;
14
+ }
15
+ interface UsePollerOptions<T> {
16
+ interval?: PollerInterval<T>;
17
+ enabled?: boolean;
18
+ immediate?: boolean;
19
+ pauseOnHidden?: boolean;
20
+ pauseOnOffline?: boolean;
21
+ backoff?: PollerBackoff | false;
22
+ stopOnError?: boolean | number;
23
+ onSuccess?: (data: T) => void;
24
+ onError?: (error: unknown) => void;
25
+ }
26
+ interface UsePollerResult<T> {
27
+ data: T | undefined;
28
+ error: unknown | null;
29
+ isPolling: boolean;
30
+ isFetching: boolean;
31
+ lastUpdated: number | null;
32
+ start: () => void;
33
+ stop: () => void;
34
+ refresh: () => Promise<T | undefined>;
35
+ }
6
36
 
7
- export { usePoller };
37
+ declare function usePoller<T>(callback: PollerCallback<T>, options?: UsePollerOptions<T>): UsePollerResult<T>;
38
+
39
+ export { type PollerBackoff, type PollerCallback, type PollerContext, type PollerInterval, type PollerIntervalContext, type UsePollerOptions, type UsePollerResult, usePoller };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,39 @@
1
- /**
2
- * Placeholder release to claim the package name on npm.
3
- * Full hook API is planned for upcoming versions.
4
- */
5
- declare function usePoller(): never;
1
+ interface PollerContext {
2
+ signal: AbortSignal;
3
+ attempt: number;
4
+ }
5
+ type PollerCallback<T> = (ctx: PollerContext) => T | Promise<T>;
6
+ type PollerIntervalContext<T> = {
7
+ data: T | undefined;
8
+ attempt: number;
9
+ };
10
+ type PollerInterval<T> = number | ((ctx: PollerIntervalContext<T>) => number);
11
+ interface PollerBackoff {
12
+ factor: number;
13
+ max: number;
14
+ }
15
+ interface UsePollerOptions<T> {
16
+ interval?: PollerInterval<T>;
17
+ enabled?: boolean;
18
+ immediate?: boolean;
19
+ pauseOnHidden?: boolean;
20
+ pauseOnOffline?: boolean;
21
+ backoff?: PollerBackoff | false;
22
+ stopOnError?: boolean | number;
23
+ onSuccess?: (data: T) => void;
24
+ onError?: (error: unknown) => void;
25
+ }
26
+ interface UsePollerResult<T> {
27
+ data: T | undefined;
28
+ error: unknown | null;
29
+ isPolling: boolean;
30
+ isFetching: boolean;
31
+ lastUpdated: number | null;
32
+ start: () => void;
33
+ stop: () => void;
34
+ refresh: () => Promise<T | undefined>;
35
+ }
6
36
 
7
- export { usePoller };
37
+ declare function usePoller<T>(callback: PollerCallback<T>, options?: UsePollerOptions<T>): UsePollerResult<T>;
38
+
39
+ export { type PollerBackoff, type PollerCallback, type PollerContext, type PollerInterval, type PollerIntervalContext, type UsePollerOptions, type UsePollerResult, usePoller };
package/dist/index.js CHANGED
@@ -1,7 +1,312 @@
1
- // src/index.ts
2
- function usePoller() {
3
- throw new Error(
4
- "use-poller@0.0.1 is a placeholder release. Full implementation is coming next."
1
+ // src/usePoller.ts
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ var DEFAULT_INTERVAL = 5e3;
4
+ var DEFAULT_BACKOFF = { factor: 2, max: 3e4 };
5
+ var isBrowser = () => typeof window !== "undefined" && typeof document !== "undefined";
6
+ var toPositiveDelay = (value) => {
7
+ if (!Number.isFinite(value) || value < 0) {
8
+ return 0;
9
+ }
10
+ return value;
11
+ };
12
+ var resolveInterval = (interval, data, attempt) => {
13
+ const raw = typeof interval === "function" ? interval({ data, attempt }) : interval;
14
+ return toPositiveDelay(raw);
15
+ };
16
+ var resolveStopOnError = (value) => {
17
+ if (value === true) {
18
+ return 1;
19
+ }
20
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
21
+ return Math.floor(value);
22
+ }
23
+ return 0;
24
+ };
25
+ function usePoller(callback, options = {}) {
26
+ const {
27
+ interval = DEFAULT_INTERVAL,
28
+ enabled = true,
29
+ immediate = true,
30
+ pauseOnHidden = true,
31
+ pauseOnOffline = false,
32
+ backoff = DEFAULT_BACKOFF,
33
+ stopOnError = false,
34
+ onSuccess,
35
+ onError
36
+ } = options;
37
+ const [data, setData] = useState(void 0);
38
+ const [error, setError] = useState(null);
39
+ const [isPolling, setIsPolling] = useState(enabled);
40
+ const [isFetching, setIsFetching] = useState(false);
41
+ const [lastUpdated, setLastUpdated] = useState(null);
42
+ const callbackRef = useRef(callback);
43
+ const onSuccessRef = useRef(onSuccess);
44
+ const onErrorRef = useRef(onError);
45
+ const intervalRef = useRef(interval);
46
+ const backoffRef = useRef(backoff);
47
+ const stopOnErrorRef = useRef(resolveStopOnError(stopOnError));
48
+ const pauseOnHiddenRef = useRef(pauseOnHidden);
49
+ const pauseOnOfflineRef = useRef(pauseOnOffline);
50
+ const enabledRef = useRef(enabled);
51
+ const dataRef = useRef(void 0);
52
+ const attemptRef = useRef(0);
53
+ const errorStreakRef = useRef(0);
54
+ const mountedRef = useRef(false);
55
+ const isFetchingRef = useRef(false);
56
+ const pollingRef = useRef(enabled);
57
+ const timerRef = useRef(null);
58
+ const activeAbortControllerRef = useRef(null);
59
+ callbackRef.current = callback;
60
+ onSuccessRef.current = onSuccess;
61
+ onErrorRef.current = onError;
62
+ intervalRef.current = interval;
63
+ backoffRef.current = backoff;
64
+ stopOnErrorRef.current = resolveStopOnError(stopOnError);
65
+ pauseOnHiddenRef.current = pauseOnHidden;
66
+ pauseOnOfflineRef.current = pauseOnOffline;
67
+ enabledRef.current = enabled;
68
+ const clearTimer = useCallback(() => {
69
+ if (timerRef.current !== null) {
70
+ clearTimeout(timerRef.current);
71
+ timerRef.current = null;
72
+ }
73
+ }, []);
74
+ const abortActiveRequest = useCallback(() => {
75
+ if (activeAbortControllerRef.current !== null) {
76
+ activeAbortControllerRef.current.abort();
77
+ activeAbortControllerRef.current = null;
78
+ isFetchingRef.current = false;
79
+ if (mountedRef.current) {
80
+ setIsFetching(false);
81
+ }
82
+ }
83
+ }, []);
84
+ const shouldPause = useCallback(() => {
85
+ if (!isBrowser()) {
86
+ return false;
87
+ }
88
+ if (pauseOnHiddenRef.current && document.visibilityState === "hidden") {
89
+ return true;
90
+ }
91
+ if (pauseOnOfflineRef.current && typeof navigator.onLine === "boolean" && !navigator.onLine) {
92
+ return true;
93
+ }
94
+ return false;
95
+ }, []);
96
+ const computeNextDelay = useCallback(() => {
97
+ const baseDelay = resolveInterval(
98
+ intervalRef.current,
99
+ dataRef.current,
100
+ attemptRef.current
101
+ );
102
+ const currentBackoff = backoffRef.current;
103
+ if (currentBackoff === false || errorStreakRef.current === 0) {
104
+ return baseDelay;
105
+ }
106
+ const backoffFactor = Math.max(currentBackoff.factor, 1);
107
+ const multipliedDelay = baseDelay * backoffFactor ** errorStreakRef.current;
108
+ return Math.min(multipliedDelay, currentBackoff.max);
109
+ }, []);
110
+ const setPollingState = useCallback((nextValue) => {
111
+ pollingRef.current = nextValue;
112
+ setIsPolling(nextValue);
113
+ }, []);
114
+ const tickRef = useRef(void 0);
115
+ const maybeResumeTick = useCallback(() => {
116
+ if (!pollingRef.current) {
117
+ return;
118
+ }
119
+ if (shouldPause()) {
120
+ return;
121
+ }
122
+ if (isFetchingRef.current || activeAbortControllerRef.current !== null) {
123
+ return;
124
+ }
125
+ if (timerRef.current !== null) {
126
+ return;
127
+ }
128
+ void tickRef.current?.();
129
+ }, [shouldPause]);
130
+ const scheduleNext = useCallback(
131
+ (forcedDelay) => {
132
+ clearTimer();
133
+ if (!mountedRef.current || !pollingRef.current) {
134
+ return;
135
+ }
136
+ if (shouldPause()) {
137
+ return;
138
+ }
139
+ const delay = forcedDelay ?? computeNextDelay();
140
+ timerRef.current = setTimeout(() => {
141
+ void tickRef.current?.();
142
+ }, delay);
143
+ },
144
+ [clearTimer, computeNextDelay, shouldPause]
145
+ );
146
+ const tick = useCallback(async () => {
147
+ if (!mountedRef.current || !pollingRef.current || shouldPause()) {
148
+ return dataRef.current;
149
+ }
150
+ clearTimer();
151
+ abortActiveRequest();
152
+ const controller = new AbortController();
153
+ activeAbortControllerRef.current = controller;
154
+ isFetchingRef.current = true;
155
+ setIsFetching(true);
156
+ attemptRef.current += 1;
157
+ try {
158
+ const result = await callbackRef.current({
159
+ signal: controller.signal,
160
+ attempt: attemptRef.current
161
+ });
162
+ if (!mountedRef.current || controller.signal.aborted) {
163
+ return dataRef.current;
164
+ }
165
+ errorStreakRef.current = 0;
166
+ setError(null);
167
+ setData(result);
168
+ dataRef.current = result;
169
+ setLastUpdated(Date.now());
170
+ onSuccessRef.current?.(result);
171
+ scheduleNext();
172
+ return result;
173
+ } catch (caught) {
174
+ if (!mountedRef.current || controller.signal.aborted) {
175
+ return dataRef.current;
176
+ }
177
+ errorStreakRef.current += 1;
178
+ setError(caught);
179
+ onErrorRef.current?.(caught);
180
+ const maxErrors = stopOnErrorRef.current;
181
+ if (maxErrors > 0 && errorStreakRef.current >= maxErrors) {
182
+ setPollingState(false);
183
+ clearTimer();
184
+ return dataRef.current;
185
+ }
186
+ scheduleNext();
187
+ return dataRef.current;
188
+ } finally {
189
+ if (activeAbortControllerRef.current === controller) {
190
+ activeAbortControllerRef.current = null;
191
+ }
192
+ isFetchingRef.current = false;
193
+ if (mountedRef.current) {
194
+ setIsFetching(false);
195
+ }
196
+ }
197
+ }, [
198
+ abortActiveRequest,
199
+ clearTimer,
200
+ scheduleNext,
201
+ setPollingState,
202
+ shouldPause
203
+ ]);
204
+ tickRef.current = tick;
205
+ const stop = useCallback(() => {
206
+ setPollingState(false);
207
+ clearTimer();
208
+ abortActiveRequest();
209
+ }, [abortActiveRequest, clearTimer, setPollingState]);
210
+ const start = useCallback(() => {
211
+ if (!enabledRef.current) {
212
+ return;
213
+ }
214
+ setPollingState(true);
215
+ maybeResumeTick();
216
+ }, [maybeResumeTick, setPollingState]);
217
+ const refresh = useCallback(async () => {
218
+ if (!enabledRef.current || !pollingRef.current) {
219
+ return dataRef.current;
220
+ }
221
+ clearTimer();
222
+ const result = await tickRef.current?.();
223
+ clearTimer();
224
+ scheduleNext();
225
+ return result ?? dataRef.current;
226
+ }, [clearTimer, scheduleNext]);
227
+ useEffect(() => {
228
+ mountedRef.current = true;
229
+ pollingRef.current = enabled;
230
+ setIsPolling(enabled);
231
+ if (enabled && immediate && !shouldPause()) {
232
+ void tickRef.current?.();
233
+ } else if (enabled && !immediate) {
234
+ scheduleNext(resolveInterval(intervalRef.current, dataRef.current, attemptRef.current));
235
+ }
236
+ return () => {
237
+ mountedRef.current = false;
238
+ clearTimer();
239
+ abortActiveRequest();
240
+ };
241
+ }, []);
242
+ useEffect(() => {
243
+ if (enabled) {
244
+ if (!pollingRef.current) {
245
+ setPollingState(true);
246
+ if (shouldPause()) {
247
+ return;
248
+ }
249
+ if (immediate) {
250
+ void tickRef.current?.();
251
+ } else {
252
+ scheduleNext(resolveInterval(intervalRef.current, dataRef.current, attemptRef.current));
253
+ }
254
+ }
255
+ return;
256
+ }
257
+ stop();
258
+ }, [enabled, immediate, scheduleNext, setPollingState, shouldPause, stop]);
259
+ useEffect(() => {
260
+ if (!isBrowser()) {
261
+ return;
262
+ }
263
+ const onVisibilityChange = () => {
264
+ if (!pauseOnHiddenRef.current) {
265
+ return;
266
+ }
267
+ if (document.visibilityState === "hidden") {
268
+ clearTimer();
269
+ return;
270
+ }
271
+ if (document.visibilityState === "visible") {
272
+ maybeResumeTick();
273
+ }
274
+ };
275
+ document.addEventListener("visibilitychange", onVisibilityChange);
276
+ return () => {
277
+ document.removeEventListener("visibilitychange", onVisibilityChange);
278
+ };
279
+ }, [clearTimer, maybeResumeTick]);
280
+ useEffect(() => {
281
+ if (!isBrowser() || !pauseOnOfflineRef.current) {
282
+ return;
283
+ }
284
+ const onOffline = () => {
285
+ clearTimer();
286
+ abortActiveRequest();
287
+ };
288
+ const onOnline = () => {
289
+ maybeResumeTick();
290
+ };
291
+ window.addEventListener("offline", onOffline);
292
+ window.addEventListener("online", onOnline);
293
+ return () => {
294
+ window.removeEventListener("offline", onOffline);
295
+ window.removeEventListener("online", onOnline);
296
+ };
297
+ }, [abortActiveRequest, clearTimer, maybeResumeTick]);
298
+ return useMemo(
299
+ () => ({
300
+ data,
301
+ error,
302
+ isPolling,
303
+ isFetching,
304
+ lastUpdated,
305
+ start,
306
+ stop,
307
+ refresh
308
+ }),
309
+ [data, error, isPolling, isFetching, lastUpdated, start, stop, refresh]
5
310
  );
6
311
  }
7
312
  export {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "use-poller",
3
- "version": "0.0.1",
4
- "description": "Initial placeholder release for the use-poller package name.",
3
+ "version": "0.1.0",
4
+ "description": "A tiny React hook for robust polling without request overlap.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
7
7
  "module": "./dist/index.js",
@@ -20,14 +20,24 @@
20
20
  ],
21
21
  "sideEffects": false,
22
22
  "scripts": {
23
+ "lint": "eslint .",
24
+ "typecheck": "tsc",
25
+ "test": "vitest run",
26
+ "test:coverage": "vitest run --coverage",
23
27
  "build": "tsup src/index.ts --format esm,cjs --dts --clean",
28
+ "size": "size-limit",
29
+ "changeset": "changeset",
30
+ "version-packages": "changeset version",
24
31
  "prepublishOnly": "npm run build"
25
32
  },
26
33
  "keywords": [
27
34
  "react",
28
35
  "hook",
29
36
  "polling",
30
- "use-poller"
37
+ "use-poller",
38
+ "interval",
39
+ "health-check",
40
+ "retry"
31
41
  ],
32
42
  "repository": {
33
43
  "type": "git",
@@ -43,10 +53,31 @@
43
53
  "react": ">=18"
44
54
  },
45
55
  "devDependencies": {
56
+ "@changesets/cli": "^2.29.5",
57
+ "@eslint/js": "^9.32.0",
58
+ "@size-limit/preset-small-lib": "^11.2.0",
59
+ "@testing-library/react": "^16.3.0",
60
+ "@types/react": "^19.1.9",
61
+ "@types/react-dom": "^19.1.7",
62
+ "@vitest/coverage-v8": "^3.2.4",
63
+ "eslint": "^9.32.0",
64
+ "jsdom": "^26.1.0",
65
+ "react": "^19.1.1",
66
+ "react-dom": "^19.1.1",
67
+ "size-limit": "^11.2.0",
46
68
  "tsup": "^8.5.0",
47
- "typescript": "^5.9.2"
69
+ "typescript": "^5.9.2",
70
+ "typescript-eslint": "^8.39.0",
71
+ "vitest": "^3.2.4"
48
72
  },
49
73
  "publishConfig": {
50
- "access": "public"
51
- }
74
+ "access": "public",
75
+ "provenance": true
76
+ },
77
+ "size-limit": [
78
+ {
79
+ "path": "dist/index.js",
80
+ "limit": "1.5 KB"
81
+ }
82
+ ]
52
83
  }