@chaosity/location-client-react 0.7.0 → 0.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.
@@ -2,10 +2,13 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { GeoPlacesClient, TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from '@chaosity/location-client';
4
4
  import debug from 'debug';
5
- import { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react';
5
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
6
6
  const log = debug('location-client-react:provider');
7
7
  const LocationClientContext = createContext(undefined);
8
8
  const DEFAULT_LIFETIME_MS = 900000;
9
+ /** The backoff after an attempt that brought no usable token (#34, #35, #36). */
10
+ const RETRY_BASE_MS = 1000;
11
+ const RETRY_CAP_MS = 30000;
9
12
  /**
10
13
  * When this token needs replacing.
11
14
  *
@@ -19,213 +22,393 @@ function expiryOf(cfg) {
19
22
  cfg.expiresAt ??
20
23
  Date.now() + DEFAULT_LIFETIME_MS);
21
24
  }
22
- export function LocationClientProvider({ children, getConfig, }) {
23
- const [client, setClient] = useState(null);
25
+ /**
26
+ * How long to wait after the `strikes`-th attempt in a row that brought no
27
+ * usable token. Exponential and capped, with full jitter so that the tabs one
28
+ * outage hit together do not all come back together, and never under the
29
+ * base: a token route that fails fast is still asked at most once a second.
30
+ */
31
+ function backoffMs(strikes) {
32
+ const ceiling = Math.min(RETRY_CAP_MS, RETRY_BASE_MS * 2 ** strikes);
33
+ return Math.max(RETRY_BASE_MS, Math.random() * ceiling);
34
+ }
35
+ /**
36
+ * The wait a failure asked for: the core's `LocationServiceException` carries
37
+ * a 429's or 503's `Retry-After` as `retryAfterMs`.
38
+ */
39
+ function retryAfterOf(err) {
40
+ const ms = err
41
+ ?.retryAfterMs;
42
+ return typeof ms === 'number' && ms > 0 ? ms : undefined;
43
+ }
44
+ function overrides(trigger, hold) {
45
+ if (trigger === 'scheduled')
46
+ return true;
47
+ if (hold.retryAfter)
48
+ return false;
49
+ if (trigger === 'user')
50
+ return true;
51
+ return trigger === 'rejected' && hold.error === null;
52
+ }
53
+ function isStale(state) {
54
+ if (!state.expiresAt)
55
+ return true;
56
+ return Date.now() >= state.expiresAt - TOKEN_REFRESH_BUFFER_SECONDS * 1000;
57
+ }
58
+ function newConfig(key, refs) {
59
+ const state = {
60
+ key,
61
+ token: undefined,
62
+ expiresAt: null,
63
+ attempt: null,
64
+ timer: null,
65
+ hold: null,
66
+ strikes: 0,
67
+ session: null,
68
+ /**
69
+ * If the token is already stale — a timer that never fired because the
70
+ * tab was backgrounded and throttled — this kicks off a refresh but cannot
71
+ * wait for it. The current read still returns the stale value; the point
72
+ * is that the NEXT one will not. While a hold stands, the refresh it asks
73
+ * for is refused.
74
+ */
75
+ getToken: () => {
76
+ if (refs.config.current !== state)
77
+ return undefined;
78
+ // `state.token` guards the pre-initialisation window: until the first
79
+ // config load lands there is no expiry to judge, and firing here would
80
+ // race the initial fetch and request a second token nobody asked for.
81
+ if (state.token && isStale(state) && !state.attempt) {
82
+ void refs.refresh.current('auto').catch(() => { });
83
+ }
84
+ return state.token;
85
+ },
86
+ };
87
+ return state;
88
+ }
89
+ const replacedError = () => new Error('This location client belongs to a configuration LocationClientProvider has since replaced or unmounted. Use the one useLocationClient() returns now.');
90
+ export function LocationClientProvider({ children, getConfig, configKey, }) {
91
+ const getConfigRef = useRef(getConfig);
92
+ // The installed configuration: null only between one being disposed and the
93
+ // next installed, and after unmount.
94
+ const configRef = useRef(null);
95
+ // `refresh` is reached from the timer, from each configuration's `getToken`
96
+ // and from the functions every client is built with; a ref breaks the cycle
97
+ // without recreating any of them.
98
+ const refreshRef = useRef(() => Promise.resolve());
99
+ const refs = useMemo(() => ({ config: configRef, refresh: refreshRef }), []);
100
+ const [config, setConfig] = useState(() => newConfig(configKey, refs));
101
+ const [session, setSession] = useState(null);
24
102
  const [loading, setLoading] = useState(true);
25
103
  const [error, setError] = useState(null);
26
- const tokenRef = useRef(undefined);
27
- const expiresAtRef = useRef(null);
28
- const getConfigRef = useRef(getConfig);
29
- const refreshPromiseRef = useRef(null);
30
- const timerRef = useRef(null);
31
- const mountedRef = useRef(true);
104
+ // A new key is a new configuration in the very render that carries it, not
105
+ // one render later from an effect: no child is handed the old client, and a
106
+ // map built now gets the `getToken` that will read the new token. React's
107
+ // pattern for adjusting state when a prop changes; `Object.is`, because
108
+ // `NaN !== NaN` would loop forever.
109
+ if (!Object.is(config.key, configKey)) {
110
+ setConfig(newConfig(configKey, refs));
111
+ setSession(null);
112
+ setLoading(true);
113
+ setError(null);
114
+ }
32
115
  useEffect(() => {
33
116
  getConfigRef.current = getConfig;
34
117
  }, [getConfig]);
35
- const isTokenExpired = useCallback(() => {
36
- if (!expiresAtRef.current)
37
- return true;
38
- return (Date.now() >= expiresAtRef.current - TOKEN_REFRESH_BUFFER_SECONDS * 1000);
39
- }, []);
40
118
  /**
41
- * Refresh once, however many callers ask at the same moment.
119
+ * Arm the timer for the next attempt: ahead of expiry, or when a hold ends.
42
120
  *
43
- * REJECTS on failure. It used to swallow the error into state and resolve,
44
- * so `send` carried on with the token it already had — guaranteeing a 401 on
45
- * the very next call and reporting it as an API error rather than a refresh
46
- * failure.
121
+ * Refreshing AHEAD of expiry is the whole fix for the map path. MapLibre's
122
+ * `transformRequest` is synchronous by contract, so `getToken` cannot await
123
+ * anything — the token it reads has to be valid already. Refresh used to
124
+ * happen only inside the `send` wrapper, which the map never calls: it
125
+ * requests tiles, glyphs and sprites directly. So after 15 minutes every map
126
+ * request failed, for as long as the page stayed open, and no amount of
127
+ * panning recovered it.
47
128
  */
48
- const refreshToken = useCallback(async () => {
49
- if (refreshPromiseRef.current)
50
- return refreshPromiseRef.current;
51
- refreshPromiseRef.current = (async () => {
52
- const cfg = await getConfigRef.current();
53
- tokenRef.current = cfg.token;
54
- expiresAtRef.current = expiryOf(cfg);
55
- log('Token refreshed (expires in %ds)', Math.floor((expiresAtRef.current - Date.now()) / 1000));
56
- if (mountedRef.current)
57
- setError(null);
58
- })();
59
- try {
60
- await refreshPromiseRef.current;
61
- scheduleRefreshRef.current();
62
- }
63
- catch (err) {
64
- const message = err instanceof Error ? err.message : 'Failed to refresh token';
65
- log('Token refresh failed: %s', message);
66
- if (mountedRef.current)
67
- setError(message);
68
- throw err;
69
- }
70
- finally {
71
- refreshPromiseRef.current = null;
72
- }
129
+ const schedule = useCallback((state, at) => {
130
+ if (state.timer)
131
+ clearTimeout(state.timer);
132
+ const delay = Math.max(0, at - Date.now());
133
+ log('Next attempt in %ds', Math.floor(delay / 1000));
134
+ state.timer = setTimeout(() => {
135
+ // Failures are already surfaced onto state by refresh; swallow here so
136
+ // a failed background attempt cannot become an unhandled rejection.
137
+ void refreshRef.current('scheduled').catch(() => { });
138
+ }, delay);
73
139
  }, []);
74
140
  /**
75
- * Refresh AHEAD of expiry, on a timer.
141
+ * The client for one configuration's `apiUrl`.
76
142
  *
77
- * This is the whole fix for the map path. MapLibre's `transformRequest` is
78
- * synchronous by contract, so `getToken` cannot await anything — the token it
79
- * reads has to be valid already. Refresh used to happen only inside the `send`
80
- * wrapper, which the map never calls: it requests tiles, glyphs and sprites
81
- * directly. So after 15 minutes every map request failed, for as long as the
82
- * page stayed open, and no amount of panning recovered it.
143
+ * Everything built here checks that its configuration is still the live one,
144
+ * and refuses once it is not (#14). Reading the provider's token regardless
145
+ * was the defect: a client kept across a switch sent the new configuration's
146
+ * token to its old URL.
83
147
  */
84
- const scheduleRefresh = useCallback(() => {
85
- if (timerRef.current)
86
- clearTimeout(timerRef.current);
87
- if (!expiresAtRef.current)
88
- return;
89
- const delay = Math.max(0, expiresAtRef.current - TOKEN_REFRESH_BUFFER_SECONDS * 1000 - Date.now());
90
- log('Next refresh in %ds', Math.floor(delay / 1000));
91
- timerRef.current = setTimeout(() => {
92
- // Errors are already surfaced onto state by refreshToken; swallow here so
93
- // a failed background refresh cannot become an unhandled rejection.
94
- void refreshToken().catch(() => { });
95
- }, delay);
96
- }, [refreshToken]);
97
- // refreshToken and scheduleRefresh reference each other; a ref breaks the cycle
98
- // without recreating either callback on every render.
99
- const scheduleRefreshRef = useRef(() => { });
100
- useEffect(() => {
101
- scheduleRefreshRef.current = scheduleRefresh;
102
- }, [scheduleRefresh]);
148
+ const build = useCallback((state, apiUrl) => {
149
+ // `session` is declared at the end; these only run after it exists.
150
+ const isLive = () => configRef.current === state && state.session === session;
151
+ /**
152
+ * The pre-send refresh. While #35's floor holds it resolves, and the send
153
+ * goes out with the token the server has just handed back.
154
+ *
155
+ * When the refresh fails, or a failed one holds (#36), the send still goes
156
+ * out with the token in hand for as long as that token is before its own
157
+ * `exp`: the API accepts it, and the map is sending it for every tile, so
158
+ * refusing a `send` then made the two paths disagree (Mehdi, 26 Sep 2026).
159
+ * Past `exp` it rejects with the refresh error, so the consumer learns the
160
+ * token endpoint is down rather than being told the API refused them.
161
+ * That is the line the provider's old behaviour missed: it swallowed the
162
+ * failure and sent whatever it held, expired or not.
163
+ */
164
+ const ready = async () => {
165
+ if (!isLive())
166
+ throw replacedError();
167
+ if (isStale(state)) {
168
+ try {
169
+ await refreshRef.current('auto');
170
+ }
171
+ catch (err) {
172
+ // A replaced client is refused on the line after this block.
173
+ const usable = state.token !== undefined &&
174
+ state.expiresAt !== null &&
175
+ Date.now() < state.expiresAt;
176
+ if (!usable)
177
+ throw err;
178
+ log('Refresh failed; sending with the token in hand until its exp');
179
+ }
180
+ }
181
+ if (!isLive())
182
+ throw replacedError();
183
+ };
184
+ const baseClient = new GeoPlacesClient({
185
+ apiUrl,
186
+ // No static `token`: the core falls back to it whenever `getToken` has
187
+ // nothing, and here that means this client has been replaced.
188
+ getToken: () => (isLive() ? state.getToken() : undefined),
189
+ /**
190
+ * The 401 escape hatch (#19).
191
+ *
192
+ * Covers what the timer cannot: a token revoked from the portal, or
193
+ * minted against a client secret since rotated, is refused by the API
194
+ * while still minutes from its own `exp` — so nothing on this side has
195
+ * any reason to replace it, and every request fails until the buffer
196
+ * finally comes around. `getToken` cannot help, being synchronous.
197
+ *
198
+ * The client awaits this after a 401 and retries the request once with
199
+ * what it returns; the same token, or nothing, means no retry, so a
200
+ * doomed request is never sent — or billed — twice.
201
+ *
202
+ * @chaosity/location-client 0.8.0 and later also calls it BEFORE the
203
+ * first send when it holds no token at all. Here that means this client
204
+ * has been replaced, which is refused below, or that `getConfig`
205
+ * answered without a token, which is asked for again as after a 401.
206
+ *
207
+ * It REJECTS when the refresh itself fails, and that is left to
208
+ * propagate out of `send` deliberately: the consumer learns the token
209
+ * endpoint is down rather than being told the API rejected them. Unlike
210
+ * the pre-flight path in `ready`, it never falls back to the token in
211
+ * hand: the API has just refused that token.
212
+ */
213
+ refreshToken: async () => {
214
+ if (!isLive())
215
+ throw replacedError();
216
+ await refreshRef.current('rejected');
217
+ if (!isLive())
218
+ throw replacedError();
219
+ return state.token;
220
+ },
221
+ });
222
+ // A plain object, not Object.create(baseClient): the prototype hack was
223
+ // opaque, and its `send` dropped the second argument entirely — so once
224
+ // the client gained `signal`/`timeoutMs`, every option passed through
225
+ // this provider would have been silently discarded.
226
+ const client = {
227
+ config: baseClient.config,
228
+ async send(command, options) {
229
+ await ready();
230
+ return baseClient.send(command, options);
231
+ },
232
+ // Behind the same pre-send refresh as `send`: forwarding it bare
233
+ // would send a stale token that `send` would have replaced (#26).
234
+ async verifyAddress(placeId, options) {
235
+ await ready();
236
+ return baseClient.verifyAddress(placeId, options);
237
+ },
238
+ // Reads whatever token the client currently holds. Deliberately not
239
+ // awaiting a refresh: this is display data, callers expect it to be
240
+ // synchronous, and a token that is minutes from expiry carries the
241
+ // same application config as its replacement will. Once this client is
242
+ // replaced it answers `{}`: the core reads through the bound `getToken`.
243
+ getAppConfig() {
244
+ return baseClient.getAppConfig();
245
+ },
246
+ };
247
+ const session = { client, apiUrl };
248
+ return session;
249
+ }, []);
103
250
  /**
104
- * Synchronous read for the map path.
251
+ * The one place `getConfig` is called: the first load, every refresh and
252
+ * every retry. Once at a time, however many callers ask at the same moment.
253
+ *
254
+ * REJECTS on failure. It used to swallow the error into state and resolve,
255
+ * so `send` carried on with the token it already had — guaranteeing a 401 on
256
+ * the very next call and reporting it as an API error rather than a refresh
257
+ * failure.
105
258
  *
106
- * If the token is already stale — a timer that never fired because the tab was
107
- * backgrounded and throttled — this kicks off a refresh but cannot wait for it.
108
- * The current read still returns the stale value; the point is that the NEXT
109
- * one will not.
259
+ * Every outcome now decides when the next attempt may start (see `Hold`).
260
+ * Nothing used to: a failed first load scheduled no retry at all and left
261
+ * `client` null for the rest of the page view (#34); a failed refresh let
262
+ * every stale read ask again at once (#36); and a token already stale by
263
+ * this clock was asked for again as soon as it arrived (#35). The first load
264
+ * was also the only call that could build a client. Now whichever attempt
265
+ * succeeds first builds it.
110
266
  */
111
- const getToken = useCallback(() => {
112
- // `tokenRef.current` guards the pre-initialisation window: until the first
113
- // config load lands there is no expiry to judge, and firing here would race
114
- // the initial fetch and request a second token nobody asked for.
115
- if (tokenRef.current && isTokenExpired() && !refreshPromiseRef.current) {
116
- log('Stale token read — refreshing in the background');
117
- void refreshToken().catch(() => { });
267
+ const refresh = useCallback((trigger) => {
268
+ const state = configRef.current;
269
+ if (!state)
270
+ return Promise.reject(replacedError());
271
+ if (state.attempt)
272
+ return state.attempt;
273
+ const { hold } = state;
274
+ if (hold && Date.now() < hold.until && !overrides(trigger, hold)) {
275
+ return hold.error === null
276
+ ? Promise.resolve()
277
+ : Promise.reject(hold.error);
118
278
  }
119
- return tokenRef.current;
120
- }, [isTokenExpired, refreshToken]);
121
- const ensureValidToken = useCallback(async () => {
122
- if (!isTokenExpired())
123
- return;
124
- await refreshToken();
125
- }, [isTokenExpired, refreshToken]);
126
- const ensureValidTokenRef = useRef(ensureValidToken);
279
+ log('Asking getConfig (%s)', trigger);
280
+ const attempt = (async () => {
281
+ let cfg;
282
+ try {
283
+ cfg = await getConfigRef.current();
284
+ }
285
+ catch (err) {
286
+ if (configRef.current !== state)
287
+ throw replacedError();
288
+ state.strikes += 1;
289
+ const retryAfterMs = retryAfterOf(err);
290
+ state.hold = {
291
+ until: Date.now() + (retryAfterMs ?? backoffMs(state.strikes)),
292
+ error: err,
293
+ retryAfter: retryAfterMs !== undefined,
294
+ };
295
+ const message = err instanceof Error
296
+ ? err.message
297
+ : state.session
298
+ ? 'Failed to refresh token'
299
+ : 'Failed to initialize client';
300
+ log('%s failed: %s', state.session ? 'Token refresh' : 'Initialization', message);
301
+ setError(message);
302
+ setLoading(false);
303
+ schedule(state, state.hold.until);
304
+ throw err;
305
+ }
306
+ if (configRef.current !== state)
307
+ throw replacedError();
308
+ // An answer naming another API is another configuration (#14), even
309
+ // under the same key: start it afresh, exactly as a `configKey` change
310
+ // would. Its token is NOT taken here, because every map and client of
311
+ // this configuration would pair it with the old URL until the switch
312
+ // lands.
313
+ if (state.session && state.session.apiUrl !== cfg.apiUrl) {
314
+ log('getConfig moved to %s — starting afresh', cfg.apiUrl);
315
+ state.session = null;
316
+ setConfig(newConfig(state.key, refs));
317
+ setSession(null);
318
+ setLoading(true);
319
+ setError(null);
320
+ return;
321
+ }
322
+ state.token = cfg.token;
323
+ state.expiresAt = expiryOf(cfg);
324
+ if (isStale(state)) {
325
+ // Stale on arrival (#35): the server judges this token fresh by its
326
+ // clock, this browser judges it stale by its own. Asking again at
327
+ // once brings the same answer, as fast as `getConfig` can give it,
328
+ // so wait — longer each time it happens again.
329
+ state.strikes += 1;
330
+ state.hold = {
331
+ until: Date.now() + backoffMs(state.strikes),
332
+ error: null,
333
+ retryAfter: false,
334
+ };
335
+ log('Token arrived already stale by this clock');
336
+ }
337
+ else {
338
+ state.strikes = 0;
339
+ state.hold = null;
340
+ }
341
+ if (!state.session) {
342
+ state.session = build(state, cfg.apiUrl);
343
+ setSession(state.session);
344
+ }
345
+ log('Token refreshed (expires in %ds)', Math.floor((state.expiresAt - Date.now()) / 1000));
346
+ setError(null);
347
+ setLoading(false);
348
+ schedule(state, Math.max(state.expiresAt - TOKEN_REFRESH_BUFFER_SECONDS * 1000, state.hold?.until ?? 0));
349
+ })();
350
+ state.attempt = attempt;
351
+ // Clears only its own slot: the next configuration has its own.
352
+ const settle = () => {
353
+ if (state.attempt === attempt)
354
+ state.attempt = null;
355
+ };
356
+ attempt.then(settle, settle);
357
+ return attempt;
358
+ }, [build, refs, schedule]);
127
359
  useEffect(() => {
128
- ensureValidTokenRef.current = ensureValidToken;
129
- }, [ensureValidToken]);
360
+ refreshRef.current = refresh;
361
+ }, [refresh]);
130
362
  /**
131
- * A backgrounded tab has its timers throttled, so the scheduled refresh can be
132
- * arbitrarily late. Refresh on the way back in, before the user touches the map.
363
+ * The tab or the network coming back.
364
+ *
365
+ * A backgrounded tab has its timers throttled, so the scheduled refresh can
366
+ * be arbitrarily late: refresh on the way back in, before the user touches
367
+ * the map. And a first load that failed gets its retry now, rather than when
368
+ * the backoff comes round (#34).
133
369
  */
134
370
  useEffect(() => {
135
371
  if (typeof document === 'undefined')
136
372
  return;
373
+ const retry = () => {
374
+ const state = configRef.current;
375
+ if (!state || (state.token && !isStale(state)))
376
+ return;
377
+ log('Back, with no fresh token — asking now');
378
+ void refreshRef.current('user').catch(() => { });
379
+ };
137
380
  const onVisible = () => {
138
- if (document.visibilityState === 'visible' &&
139
- tokenRef.current &&
140
- isTokenExpired()) {
141
- log('Tab visible again with a stale token — refreshing');
142
- void refreshToken().catch(() => { });
143
- }
381
+ if (document.visibilityState === 'visible')
382
+ retry();
144
383
  };
145
384
  document.addEventListener('visibilitychange', onVisible);
146
- return () => document.removeEventListener('visibilitychange', onVisible);
147
- }, [isTokenExpired, refreshToken]);
385
+ window.addEventListener('online', retry);
386
+ return () => {
387
+ document.removeEventListener('visibilitychange', onVisible);
388
+ window.removeEventListener('online', retry);
389
+ };
390
+ }, []);
391
+ // One configuration at a time, installed here and disposed with it.
148
392
  useEffect(() => {
149
- mountedRef.current = true;
150
393
  log('Initializing LocationClientProvider');
151
- getConfigRef
152
- .current()
153
- .then((cfg) => {
154
- if (!mountedRef.current)
155
- return;
156
- tokenRef.current = cfg.token;
157
- expiresAtRef.current = expiryOf(cfg);
158
- const baseClient = new GeoPlacesClient({
159
- apiUrl: cfg.apiUrl,
160
- token: cfg.token,
161
- getToken,
162
- /**
163
- * The 401 escape hatch (#19).
164
- *
165
- * Covers what the timer cannot: a token revoked from the portal, or
166
- * minted against a client secret since rotated, is refused by the API
167
- * while still minutes from its own `exp` — so nothing on this side has
168
- * any reason to replace it, and every request fails until the buffer
169
- * finally comes around. `getToken` cannot help, being synchronous.
170
- *
171
- * The client awaits this after a 401 and retries the request once with
172
- * what it returns; the same token, or nothing, means no retry, so a
173
- * doomed request is never sent — or billed — twice.
174
- *
175
- * It REJECTS when the refresh itself fails, and that is left to
176
- * propagate out of `send` deliberately: the consumer learns the token
177
- * endpoint is down rather than being told the API rejected them. Same
178
- * answer the pre-flight `ensureValidToken` path already gives.
179
- */
180
- refreshToken: async () => {
181
- await refreshToken();
182
- return tokenRef.current;
183
- },
184
- });
185
- // A plain object, not Object.create(baseClient): the prototype hack was
186
- // opaque, and its `send` dropped the second argument entirely — so once
187
- // the client gained `signal`/`timeoutMs`, every option passed through
188
- // this provider would have been silently discarded.
189
- const refreshing = {
190
- config: baseClient.config,
191
- async send(command, options) {
192
- await ensureValidTokenRef.current();
193
- return baseClient.send(command, options);
194
- },
195
- // Behind the same pre-send refresh as `send`: forwarding it bare
196
- // would send a stale token that `send` would have replaced (#26).
197
- async verifyAddress(placeId, options) {
198
- await ensureValidTokenRef.current();
199
- return baseClient.verifyAddress(placeId, options);
200
- },
201
- // Reads whatever token the client currently holds. Deliberately not
202
- // awaiting a refresh: this is display data, callers expect it to be
203
- // synchronous, and a token that is minutes from expiry carries the
204
- // same application config as its replacement will.
205
- getAppConfig() {
206
- return baseClient.getAppConfig();
207
- },
208
- };
209
- setClient(refreshing);
210
- scheduleRefreshRef.current();
211
- log('Client initialized (token expires in %ds)', Math.floor((expiresAtRef.current - Date.now()) / 1000));
212
- setLoading(false);
213
- })
214
- .catch((err) => {
215
- if (!mountedRef.current)
216
- return;
217
- const message = err instanceof Error ? err.message : 'Failed to initialize client';
218
- log('Initialization failed: %s', message);
219
- setError(message);
220
- setLoading(false);
221
- });
394
+ configRef.current = config;
395
+ void refresh('scheduled').catch(() => { });
222
396
  return () => {
223
- mountedRef.current = false;
224
- if (timerRef.current)
225
- clearTimeout(timerRef.current);
397
+ // An answer still in flight finds the configuration gone, and writes
398
+ // nothing; every client and `getToken` of it refuses from now on.
399
+ if (config.timer)
400
+ clearTimeout(config.timer);
401
+ configRef.current = null;
226
402
  };
227
- }, [getToken, refreshToken]);
228
- return (_jsx(LocationClientContext.Provider, { value: { client, getToken, loading, error }, children: children }));
403
+ }, [config, refresh]);
404
+ const value = useMemo(() => ({
405
+ client: session?.client ?? null,
406
+ getToken: config.getToken,
407
+ apiUrl: session?.apiUrl ?? null,
408
+ loading,
409
+ error,
410
+ }), [config, session, loading, error]);
411
+ return (_jsx(LocationClientContext.Provider, { value: value, children: children }));
229
412
  }
230
413
  export function useLocationClient() {
231
414
  const context = useContext(LocationClientContext);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaosity/location-client-react",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "React bindings for Chaosity Location Service client",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",
@@ -40,7 +40,7 @@
40
40
  "peerDependencies": {
41
41
  "@chaosity/location-client": ">=0.10.0",
42
42
  "maplibre-gl": "^5.0.0",
43
- "react": "^18.0.0 || ^19.0.0"
43
+ "react": "^19.0.0"
44
44
  },
45
45
  "peerDependenciesMeta": {
46
46
  "maplibre-gl": {