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