use-weather-forecast 0.1.166 → 0.1.167
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 +43 -11
- package/dist/api/forecast.d.ts +14 -6
- package/dist/api/http.d.ts +40 -4
- package/dist/api/providers/open-meteo.d.ts +14 -1
- package/dist/hooks/useWeatherForecast.d.ts +5 -1
- package/dist/index.cjs +142 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +142 -52
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,18 +68,37 @@ the package does when an upstream misbehaves.
|
|
|
68
68
|
|
|
69
69
|
### `Weather request failed: 400 Bad Request`
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
`
|
|
75
|
-
|
|
71
|
+
Two different things produced this, and both are fixed.
|
|
72
|
+
|
|
73
|
+
**A malformed URL.** Requests go through
|
|
74
|
+
[`grab-url`](https://www.npmjs.com/package/grab-url), which turns every option
|
|
75
|
+
it does not recognise into the GET query string and concatenates it onto the
|
|
76
|
+
path. Passing it a URL that already had a query gave the request *two* `?`, and
|
|
77
|
+
the last real parameter value absorbed the leftover option:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
...&daily=temperature_2m_max%2Cwind_speed_10m_max?retryAttempts=0
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Open-Meteo read that as an unknown `daily` variable and answered `400`. It hit
|
|
84
|
+
every weather provider on every call, since all four build a query -- while the
|
|
85
|
+
geolocation lookups kept working, because their URLs carry no query of their own
|
|
86
|
+
for the stray `?` to collide with. The query is now handed over as grab-url's
|
|
87
|
+
own parameters, so grab-url writes the single `?` itself and there is no query
|
|
88
|
+
string of ours left to corrupt.
|
|
89
|
+
|
|
90
|
+
**A malformed query.** Open-Meteo also answers `400` for a query it cannot
|
|
91
|
+
parse, and the usual way to build one is not a typo in your props: an IP
|
|
92
|
+
geolocation upstream answers `200` with no coordinates in the body,
|
|
93
|
+
`Number(undefined)` becomes `NaN`, `latitude=NaN` goes out on the wire.
|
|
94
|
+
Everything that goes into a request is validated first:
|
|
76
95
|
|
|
77
96
|
| Input | What happens to it |
|
|
78
97
|
| --- | --- |
|
|
79
|
-
| `latitude` / `longitude` | Must be finite and on the globe, and are rounded to 4 decimals. A geolocation response without them counts as a failed lookup, so the next provider gets a turn. Unusable props fall back to IP geolocation. |
|
|
80
|
-
| `location.timezone` | Canonicalized (`US/Pacific` becomes `America/Los_Angeles`); a zone the runtime cannot resolve is dropped rather than sent, and Open-Meteo resolves
|
|
98
|
+
| `latitude` / `longitude` | Must be finite and on the globe, and are rounded to 4 decimals. A geolocation response without them counts as a failed lookup, so the next provider gets a turn. Unusable props fall back to IP geolocation. Coordinates on `location` are honoured like top-level ones, without an IP lookup. |
|
|
99
|
+
| `location.timezone` | Canonicalized (`US/Pacific` becomes `America/Los_Angeles`); a zone the runtime cannot resolve is dropped rather than sent. With no zone given, the one IP geolocation reported is used, and Open-Meteo resolves it from the coordinates if there is none. |
|
|
81
100
|
| `forecastDays` / `forecastHours` | Clamped to the 1-16 days and 1-384 hours the API accepts. |
|
|
82
|
-
| Variable list | A `400` from an endpoint that refused
|
|
101
|
+
| Variable list | A `400` from an endpoint that refused part of the query is asked again in a smaller shape: first only the variables every model supports, then that without `forecast_hours` and with `timezone=auto`. The units are never dropped -- a Celsius body read as Fahrenheit would render a plausible, wrong temperature. |
|
|
83
102
|
|
|
84
103
|
### Retries
|
|
85
104
|
|
|
@@ -104,9 +123,20 @@ upstream's rate limit.
|
|
|
104
123
|
The fallbacks normalize their own condition codes, units and timestamps into
|
|
105
124
|
the same shape Open-Meteo returns, so a failover is invisible in the rendered
|
|
106
125
|
widget (wttr.in is 3-hourly rather than hourly). If every provider fails, a
|
|
107
|
-
cached forecast up to a day old is served instead of throwing
|
|
108
|
-
|
|
109
|
-
|
|
126
|
+
cached forecast up to a day old is served instead of throwing.
|
|
127
|
+
|
|
128
|
+
### Reloading
|
|
129
|
+
|
|
130
|
+
If even that produces nothing, the component reloads the whole forecast
|
|
131
|
+
`reloadAttempts` more times (2 by default) with a growing pause -- 3s, then 6s.
|
|
132
|
+
A failure that reaches this point means every provider failed at once, which is
|
|
133
|
+
usually something that clears on its own: a flaky network on first paint, or
|
|
134
|
+
every free upstream rate-limited in the same second. The component stays in its
|
|
135
|
+
loading state while a reload is pending, so it renders nothing for a few
|
|
136
|
+
seconds rather than flashing an error it is about to recover from.
|
|
137
|
+
|
|
138
|
+
Only once the reloads are spent does an error reach the widget, listing what
|
|
139
|
+
each provider said. `reloadAttempts={0}` shows it immediately instead.
|
|
110
140
|
|
|
111
141
|
```tsx
|
|
112
142
|
<WeatherForecast
|
|
@@ -115,6 +145,8 @@ said.
|
|
|
115
145
|
retryAttempts={3}
|
|
116
146
|
retryDelay={400}
|
|
117
147
|
timeout={15}
|
|
148
|
+
reloadAttempts={2}
|
|
149
|
+
reloadDelay={3000}
|
|
118
150
|
fallbackLocation={{ city: 'Austin', latitude: 30.27, longitude: -97.74 }}
|
|
119
151
|
allowStaleCache
|
|
120
152
|
onProviderError={({ provider, stage, error }) => console.warn(stage, provider, error.message)}
|
package/dist/api/forecast.d.ts
CHANGED
|
@@ -4,12 +4,20 @@ import { type ForecastRequest } from './providers';
|
|
|
4
4
|
* Everything that goes on the wire is validated first, then the providers are
|
|
5
5
|
* tried in order until one answers.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* Two different things produced the `400 Bad Request` this used to render, and
|
|
8
|
+
* both are handled, in different places:
|
|
9
|
+
*
|
|
10
|
+
* - The **request** could be malformed. A geolocation upstream answering 200
|
|
11
|
+
* with no coordinates put `latitude=NaN` in the URL; an unknown timezone
|
|
12
|
+
* string or an out-of-range `forecastDays` did the same. Those are caught in
|
|
13
|
+
* {@link buildForecastRequest}, before a request is sent.
|
|
14
|
+
* - The **URL** could be malformed after it left here, by the transport rather
|
|
15
|
+
* than by anything in this file -- see `splitUrl` in `./http`. That one broke
|
|
16
|
+
* every provider on every call, which is why the fallbacks below could not
|
|
17
|
+
* rescue it.
|
|
18
|
+
*
|
|
19
|
+
* Anything that still fails falls through to the next provider and finally to a
|
|
20
|
+
* stale cache entry.
|
|
13
21
|
*/
|
|
14
22
|
/** Cache key for a request, so a fallback provider's answer is reused like the primary one's. */
|
|
15
23
|
export declare function forecastCacheKey(request: ForecastRequest): string;
|
package/dist/api/http.d.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*
|
|
11
11
|
* On top of that this module adds what the upstreams actually need to stay up:
|
|
12
12
|
* a classified retry loop (repeat what can recover, give up immediately on a
|
|
13
|
-
* request the server called invalid) with exponential backoff
|
|
13
|
+
* request the server called invalid) with exponential backoff, and it hands
|
|
14
|
+
* grab-url a query string it cannot corrupt -- see {@link splitUrl}.
|
|
14
15
|
*/
|
|
15
16
|
export type GrabJsonOptions = {
|
|
16
17
|
headers?: Record<string, string>;
|
|
@@ -21,9 +22,15 @@ export type GrabJsonOptions = {
|
|
|
21
22
|
/** default=15 Seconds before a single try is aborted. */
|
|
22
23
|
timeout?: number;
|
|
23
24
|
/**
|
|
24
|
-
* default=0
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* default=0 Extra tries inside each of our `attempts`, run back-to-back with
|
|
26
|
+
* no backoff between them.
|
|
27
|
+
*
|
|
28
|
+
* This used to be forwarded to grab-url's own option of the same name. It no
|
|
29
|
+
* longer is -- grab-url turns any option it does not recognise into a query
|
|
30
|
+
* parameter, and `retryAttempts` is one it does not recognise, so forwarding
|
|
31
|
+
* it appended `?retryAttempts=0` to every URL (see {@link splitUrl}). The
|
|
32
|
+
* repeat is done here instead, which keeps the same try count without
|
|
33
|
+
* touching the URL.
|
|
27
34
|
*/
|
|
28
35
|
retryAttempts?: number;
|
|
29
36
|
/**
|
|
@@ -44,6 +51,35 @@ export declare class HttpRequestError extends Error {
|
|
|
44
51
|
}
|
|
45
52
|
/** No status at all means the request never reached the server -- worth a repeat. */
|
|
46
53
|
export declare function isRetryableStatus(status?: number): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Split a full URL into the path grab-url should request and the query it
|
|
56
|
+
* should build, because grab-url will not leave a query string we wrote alone.
|
|
57
|
+
*
|
|
58
|
+
* grab-url destructures the options it knows, then turns **every remaining
|
|
59
|
+
* option** into the GET query string and concatenates it onto the path. So a
|
|
60
|
+
* URL that already carries a `?` comes out with two of them, and the last real
|
|
61
|
+
* value silently absorbs the leftover option:
|
|
62
|
+
*
|
|
63
|
+
* ```text
|
|
64
|
+
* ...&daily=temperature_2m_max%2Cwind_speed_10m_max?retryAttempts=0
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* Open-Meteo reads that trailing segment as part of the last `daily` variable,
|
|
68
|
+
* does not recognise it, and answers `400 Bad Request` -- which is exactly the
|
|
69
|
+
* `Weather request failed: 400 Bad Request` the widget used to render, on every
|
|
70
|
+
* weather provider (they all build a query), while the geolocation lookups kept
|
|
71
|
+
* working because their URLs have no query of their own for the stray `?` to
|
|
72
|
+
* collide with.
|
|
73
|
+
*
|
|
74
|
+
* Handing the query over as grab-url's own params instead means grab-url writes
|
|
75
|
+
* the single `?` itself, so there is no query string of ours left to corrupt --
|
|
76
|
+
* and an option it fails to recognise lands as one more harmless parameter
|
|
77
|
+
* rather than inside the value of a real one.
|
|
78
|
+
*/
|
|
79
|
+
export declare function splitUrl(url: string): {
|
|
80
|
+
path: string;
|
|
81
|
+
params: Record<string, string>;
|
|
82
|
+
};
|
|
47
83
|
/**
|
|
48
84
|
* Request a JSON body, repeating what can recover and throwing a labelled
|
|
49
85
|
* {@link HttpRequestError} once it cannot.
|
|
@@ -6,7 +6,20 @@ declare const FULL_VARIABLES: {
|
|
|
6
6
|
daily: string[];
|
|
7
7
|
};
|
|
8
8
|
type Variables = typeof FULL_VARIABLES;
|
|
9
|
-
export
|
|
9
|
+
export type OpenMeteoQuery = {
|
|
10
|
+
/** default=FULL_VARIABLES Which variables to ask for. */
|
|
11
|
+
variables?: Variables;
|
|
12
|
+
/**
|
|
13
|
+
* default=false Leave out the range and zone parameters an endpoint can
|
|
14
|
+
* refuse on their own: `forecast_hours`, and a `timezone` other than `auto`.
|
|
15
|
+
*
|
|
16
|
+
* Only parameters that cannot change what the numbers *mean* are dropped --
|
|
17
|
+
* the units stay, since a Celsius body read as Fahrenheit would render a
|
|
18
|
+
* plausible, wrong temperature rather than an error.
|
|
19
|
+
*/
|
|
20
|
+
omitOptional?: boolean;
|
|
21
|
+
};
|
|
22
|
+
export declare function buildOpenMeteoUrl(endpoint: string, request: ForecastRequest, query?: OpenMeteoQuery | Variables): string;
|
|
10
23
|
/**
|
|
11
24
|
* Open-Meteo, the package's primary source.
|
|
12
25
|
*
|
|
@@ -4,7 +4,11 @@ import type { WeatherForecastData, WeatherForecastOptions } from '../types';
|
|
|
4
4
|
*
|
|
5
5
|
* The last good forecast is kept while a refetch is in flight *and* if that
|
|
6
6
|
* refetch fails, so a rate-limited upstream leaves the widget showing the
|
|
7
|
-
* previous data rather than replacing it with an error.
|
|
7
|
+
* previous data rather than replacing it with an error. A first load that
|
|
8
|
+
* fails outright is retried `reloadAttempts` times with a growing pause, and
|
|
9
|
+
* stays in the loading state while a retry is pending -- the widget renders
|
|
10
|
+
* nothing for a few seconds rather than flashing an error it is about to
|
|
11
|
+
* recover from.
|
|
8
12
|
*/
|
|
9
13
|
export declare function useWeatherForecast(options?: WeatherForecastOptions): {
|
|
10
14
|
data: WeatherForecastData | null;
|
package/dist/index.cjs
CHANGED
|
@@ -165,11 +165,63 @@ function unwrapBody(payload) {
|
|
|
165
165
|
}
|
|
166
166
|
return payload;
|
|
167
167
|
}
|
|
168
|
+
function splitUrl(url) {
|
|
169
|
+
const separator = url.indexOf("?");
|
|
170
|
+
if (separator === -1) return { path: url, params: {} };
|
|
171
|
+
const params = {};
|
|
172
|
+
for (const [key, value] of new URLSearchParams(url.slice(separator + 1))) params[key] = value;
|
|
173
|
+
return { path: url.slice(0, separator), params };
|
|
174
|
+
}
|
|
175
|
+
var GRAB_RESERVED_OPTIONS = /* @__PURE__ */ new Set([
|
|
176
|
+
"headers",
|
|
177
|
+
"response",
|
|
178
|
+
"method",
|
|
179
|
+
"cache",
|
|
180
|
+
"timeout",
|
|
181
|
+
"baseURL",
|
|
182
|
+
"cancelOngoingIfNew",
|
|
183
|
+
"cancelNewIfOngoing",
|
|
184
|
+
"rateLimit",
|
|
185
|
+
"debug",
|
|
186
|
+
"infiniteScroll",
|
|
187
|
+
"logger",
|
|
188
|
+
"onRequest",
|
|
189
|
+
"onResponse",
|
|
190
|
+
"onError",
|
|
191
|
+
"onStream",
|
|
192
|
+
"unzip",
|
|
193
|
+
"dom",
|
|
194
|
+
"body",
|
|
195
|
+
"post",
|
|
196
|
+
"put",
|
|
197
|
+
"patch",
|
|
198
|
+
"debounce",
|
|
199
|
+
"repeat",
|
|
200
|
+
"repeatEvery",
|
|
201
|
+
"setDefaults",
|
|
202
|
+
"regrabOnStale",
|
|
203
|
+
"regrabOnFocus",
|
|
204
|
+
"regrabOnNetwork",
|
|
205
|
+
"cacheForTime",
|
|
206
|
+
"retryAttempts"
|
|
207
|
+
]);
|
|
168
208
|
async function grabJsonOnce(url, label, options) {
|
|
169
|
-
const { headers,
|
|
170
|
-
const
|
|
209
|
+
const { headers, timeout = 15 } = options;
|
|
210
|
+
const { path, params } = splitUrl(url);
|
|
211
|
+
const reserved = Object.keys(params).filter((key) => GRAB_RESERVED_OPTIONS.has(key));
|
|
212
|
+
if (reserved.length > 0) {
|
|
213
|
+
throw new HttpRequestError(
|
|
214
|
+
`${label} failed: ${reserved.join(", ")} cannot be sent as a query parameter`,
|
|
215
|
+
{ retryable: false }
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const data = await (0, import_grab_url.default)(path, {
|
|
219
|
+
// grab-url builds the query from the options it does not recognise, so the
|
|
220
|
+
// request's own parameters are passed as exactly that -- never as a query
|
|
221
|
+
// string on `path`. Anything added here has to be an option grab-url knows,
|
|
222
|
+
// or it goes out on the wire as a parameter.
|
|
223
|
+
...params,
|
|
171
224
|
headers,
|
|
172
|
-
retryAttempts,
|
|
173
225
|
timeout,
|
|
174
226
|
// Several widgets (or several locations in one widget) hit the same path
|
|
175
227
|
// at once, and grab-url aborts the earlier call of a duplicate path by
|
|
@@ -196,16 +248,21 @@ async function grabJsonOnce(url, label, options) {
|
|
|
196
248
|
async function grabJson(url, label, options = {}) {
|
|
197
249
|
const attempts = Math.max(1, Math.trunc(options.attempts ?? 3));
|
|
198
250
|
const retryDelay = Math.max(0, options.retryDelay ?? 400);
|
|
251
|
+
const immediate = Math.max(0, Math.trunc(options.retryAttempts ?? 0));
|
|
199
252
|
let lastError;
|
|
200
253
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
254
|
+
let failure;
|
|
255
|
+
for (let immediateTry = 0; immediateTry <= immediate; immediateTry++) {
|
|
256
|
+
try {
|
|
257
|
+
return await grabJsonOnce(url, label, options);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
failure = error;
|
|
260
|
+
}
|
|
208
261
|
}
|
|
262
|
+
lastError = failure;
|
|
263
|
+
const retryable = !(failure instanceof HttpRequestError) || failure.retryable || options.retryClientErrors === true;
|
|
264
|
+
if (!retryable || attempt === attempts) break;
|
|
265
|
+
if (retryDelay > 0) await wait(retryDelay * 2 ** (attempt - 1));
|
|
209
266
|
}
|
|
210
267
|
throw lastError instanceof Error ? lastError : new HttpRequestError(String(lastError));
|
|
211
268
|
}
|
|
@@ -440,15 +497,16 @@ var MINIMAL_VARIABLES = {
|
|
|
440
497
|
hourly: ["temperature_2m", "weather_code"],
|
|
441
498
|
daily: ["temperature_2m_max", "temperature_2m_min", "weather_code"]
|
|
442
499
|
};
|
|
443
|
-
function buildOpenMeteoUrl(endpoint, request,
|
|
500
|
+
function buildOpenMeteoUrl(endpoint, request, query = {}) {
|
|
501
|
+
const { variables = FULL_VARIABLES, omitOptional = false } = "current" in query ? { variables: query } : query;
|
|
444
502
|
const params = new URLSearchParams({
|
|
445
503
|
latitude: String(request.location.latitude),
|
|
446
504
|
longitude: String(request.location.longitude),
|
|
447
|
-
timezone: request.timezone || "auto",
|
|
505
|
+
timezone: omitOptional ? "auto" : request.timezone || "auto",
|
|
448
506
|
temperature_unit: request.temperatureUnit,
|
|
449
507
|
wind_speed_unit: request.windSpeedUnit,
|
|
450
508
|
forecast_days: String(request.forecastDays),
|
|
451
|
-
forecast_hours: String(request.forecastHours),
|
|
509
|
+
...omitOptional ? {} : { forecast_hours: String(request.forecastHours) },
|
|
452
510
|
current: variables.current.join(","),
|
|
453
511
|
hourly: variables.hourly.join(","),
|
|
454
512
|
daily: variables.daily.join(",")
|
|
@@ -505,24 +563,28 @@ function createOpenMeteoProvider(id, label, endpoint) {
|
|
|
505
563
|
id,
|
|
506
564
|
label,
|
|
507
565
|
async fetchForecast(request) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
566
|
+
const shapes = [
|
|
567
|
+
{},
|
|
568
|
+
{ variables: MINIMAL_VARIABLES },
|
|
569
|
+
{ variables: MINIMAL_VARIABLES, omitOptional: true }
|
|
570
|
+
];
|
|
571
|
+
let lastError;
|
|
572
|
+
for (const [index, shape] of shapes.entries()) {
|
|
573
|
+
try {
|
|
574
|
+
const data = await grabJson(
|
|
575
|
+
buildOpenMeteoUrl(endpoint, request, shape),
|
|
576
|
+
"Weather request",
|
|
577
|
+
request.transport
|
|
578
|
+
);
|
|
579
|
+
return parse(data, request);
|
|
580
|
+
} catch (error) {
|
|
581
|
+
lastError = error;
|
|
582
|
+
if (!(error instanceof HttpRequestError)) throw error;
|
|
583
|
+
const invalidQuery = error.status === 400 || /cannot initialize|invalid|not supported/i.test(error.message);
|
|
584
|
+
if (!invalidQuery || index === shapes.length - 1) throw error;
|
|
585
|
+
}
|
|
525
586
|
}
|
|
587
|
+
throw lastError;
|
|
526
588
|
}
|
|
527
589
|
};
|
|
528
590
|
}
|
|
@@ -838,7 +900,7 @@ function forecastCacheKey(request) {
|
|
|
838
900
|
].join("|");
|
|
839
901
|
}
|
|
840
902
|
async function resolveLocation(options) {
|
|
841
|
-
const explicit = normalizeCoordinates(options.latitude, options.longitude);
|
|
903
|
+
const explicit = normalizeCoordinates(options.latitude, options.longitude) ?? normalizeCoordinates(options.location?.latitude, options.location?.longitude);
|
|
842
904
|
if (explicit) {
|
|
843
905
|
return {
|
|
844
906
|
...options.location,
|
|
@@ -870,7 +932,10 @@ async function buildForecastRequest(options = {}) {
|
|
|
870
932
|
// `undefined` means "let the provider resolve the zone from the
|
|
871
933
|
// coordinates" (Open-Meteo's `timezone=auto`); an unrecognised zone string
|
|
872
934
|
// is dropped here rather than sent, since Open-Meteo answers 400 for one.
|
|
873
|
-
|
|
935
|
+
// A zone the caller gave wins over the one geolocation reported, but the
|
|
936
|
+
// geolocated zone is still used rather than discarded -- it is what makes
|
|
937
|
+
// the clock read in the location's own time on the first render.
|
|
938
|
+
timezone: normalizeTimezone(options.location?.timezone) ?? location.timezone,
|
|
874
939
|
transport
|
|
875
940
|
};
|
|
876
941
|
}
|
|
@@ -901,31 +966,18 @@ async function getWeatherForecast(options = {}) {
|
|
|
901
966
|
|
|
902
967
|
// src/hooks/useWeatherForecast.ts
|
|
903
968
|
var import_react = require("react");
|
|
969
|
+
var DEFAULT_RELOAD_ATTEMPTS = 2;
|
|
970
|
+
var DEFAULT_RELOAD_DELAY_MS = 3e3;
|
|
904
971
|
function providerKey(options) {
|
|
905
972
|
const ids = (list) => (list ?? []).map((provider) => typeof provider === "string" ? provider : provider.id).join(",");
|
|
906
973
|
return `${ids(options.weatherProviders)}|${ids(options.geoProviders)}`;
|
|
907
974
|
}
|
|
908
|
-
function
|
|
909
|
-
|
|
910
|
-
const [error, setError] = (0, import_react.useState)(null);
|
|
911
|
-
const [loading, setLoading] = (0, import_react.useState)(true);
|
|
912
|
-
(0, import_react.useEffect)(() => {
|
|
913
|
-
let active = true;
|
|
914
|
-
setLoading(true);
|
|
915
|
-
setError(null);
|
|
916
|
-
getWeatherForecast(options).then((result) => {
|
|
917
|
-
if (active) setData(result);
|
|
918
|
-
}).catch((err) => {
|
|
919
|
-
if (active) setError(err instanceof Error ? err : new Error("Unknown error"));
|
|
920
|
-
}).finally(() => {
|
|
921
|
-
if (active) setLoading(false);
|
|
922
|
-
});
|
|
923
|
-
return () => {
|
|
924
|
-
active = false;
|
|
925
|
-
};
|
|
926
|
-
}, [
|
|
975
|
+
function requestKey(options) {
|
|
976
|
+
return [
|
|
927
977
|
options.latitude,
|
|
928
978
|
options.longitude,
|
|
979
|
+
options.location?.latitude,
|
|
980
|
+
options.location?.longitude,
|
|
929
981
|
options.geoEndpoint,
|
|
930
982
|
options.ip,
|
|
931
983
|
options.forecastDays,
|
|
@@ -937,10 +989,48 @@ function useWeatherForecast(options = {}) {
|
|
|
937
989
|
options.retryDelay,
|
|
938
990
|
options.timeout,
|
|
939
991
|
options.allowStaleCache,
|
|
992
|
+
options.reloadAttempts,
|
|
993
|
+
options.reloadDelay,
|
|
940
994
|
options.fallbackLocation?.latitude,
|
|
941
995
|
options.fallbackLocation?.longitude,
|
|
942
996
|
providerKey(options)
|
|
943
|
-
]);
|
|
997
|
+
].join("|");
|
|
998
|
+
}
|
|
999
|
+
function useWeatherForecast(options = {}) {
|
|
1000
|
+
const [data, setData] = (0, import_react.useState)(null);
|
|
1001
|
+
const [error, setError] = (0, import_react.useState)(null);
|
|
1002
|
+
const [loading, setLoading] = (0, import_react.useState)(true);
|
|
1003
|
+
const reloadAttempts = Math.max(0, Math.trunc(options.reloadAttempts ?? DEFAULT_RELOAD_ATTEMPTS));
|
|
1004
|
+
const reloadDelay = Math.max(0, options.reloadDelay ?? DEFAULT_RELOAD_DELAY_MS);
|
|
1005
|
+
const key = requestKey(options);
|
|
1006
|
+
const [reload, setReload] = (0, import_react.useState)({ key, count: 0 });
|
|
1007
|
+
if (reload.key !== key) setReload({ key, count: 0 });
|
|
1008
|
+
(0, import_react.useEffect)(() => {
|
|
1009
|
+
let active = true;
|
|
1010
|
+
let timer;
|
|
1011
|
+
setLoading(true);
|
|
1012
|
+
setError(null);
|
|
1013
|
+
getWeatherForecast(options).then((result) => {
|
|
1014
|
+
if (!active) return;
|
|
1015
|
+
setData(result);
|
|
1016
|
+
setLoading(false);
|
|
1017
|
+
}).catch((err) => {
|
|
1018
|
+
if (!active) return;
|
|
1019
|
+
setError(err instanceof Error ? err : new Error("Unknown error"));
|
|
1020
|
+
if (reload.count >= reloadAttempts) {
|
|
1021
|
+
setLoading(false);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
timer = setTimeout(
|
|
1025
|
+
() => setReload((current) => ({ key: current.key, count: current.count + 1 })),
|
|
1026
|
+
reloadDelay * 2 ** reload.count
|
|
1027
|
+
);
|
|
1028
|
+
});
|
|
1029
|
+
return () => {
|
|
1030
|
+
active = false;
|
|
1031
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1032
|
+
};
|
|
1033
|
+
}, [key, reload.count]);
|
|
944
1034
|
return { data, error, loading };
|
|
945
1035
|
}
|
|
946
1036
|
|