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 +127 -9
- package/dist/index.cjs +310 -3
- package/dist/index.d.cts +38 -6
- package/dist/index.d.ts +38 -6
- package/dist/index.js +309 -4
- package/package.json +37 -6
package/README.md
CHANGED
|
@@ -1,20 +1,138 @@
|
|
|
1
1
|
# use-poller
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Polling done right for the cases where cache-oriented query tools are not the right fit.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`use-poller` is a tiny React hook for robust async polling with:
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
##
|
|
13
|
+
## Behavior guarantees
|
|
10
14
|
|
|
11
|
-
|
|
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
|
-
##
|
|
20
|
+
## Install
|
|
14
21
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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/
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
4
|
-
"description": "
|
|
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
|
}
|