@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.
package/README.md CHANGED
@@ -128,7 +128,8 @@ Provides the location client and automatic token refresh to all child components
128
128
 
129
129
  **Props:**
130
130
 
131
- - `getConfig` — Async function that returns `{ apiUrl: string, token: string, expiresAt?: number }`. Called on init and whenever the token needs refreshing.
131
+ - `getConfig` — Async function that returns `{ apiUrl: string, token: string, expiresAt?: number }`. Called on mount, whenever the token needs refreshing, and to retry a call that failed (see [Token Refresh](#token-refresh)).
132
+ - `configKey` — Optional. What `getConfig` answers for, such as an organisation or application id. When it changes, the provider drops the old client, token and `apiUrl`, calls `getConfig` again and hands out a new client, without remounting its children.
132
133
  - `children` — Child components.
133
134
 
134
135
  There is no `refreshBuffer` prop. It was removed in `0.3.0` — a value shorter
@@ -137,20 +138,47 @@ the server would not yet replace, and the two spun against each other. Both
137
138
  sides now apply the same buffer to the token's own `exp`. Passing it does
138
139
  nothing.
139
140
 
141
+ #### Switching organisation or application
142
+
143
+ Either of these is correct:
144
+
145
+ ```tsx
146
+ // Keeps the children mounted: only the client is replaced.
147
+ <LocationClientProvider configKey={orgId} getConfig={getLocationConfig}>
148
+
149
+ // Remounts everything below the provider, and its state with it.
150
+ <LocationClientProvider key={orgId} getConfig={getLocationConfig}>
151
+ ```
152
+
153
+ Without either, the provider goes on using the old configuration until its
154
+ next token refresh, up to 14 minutes later. A new `getConfig` function is not
155
+ a signal on its own, because it is usually a new function on every render.
156
+
157
+ After a switch, a client or `getToken` kept from before it refuses: `send()`
158
+ and `verifyAddress()` reject, `getToken()` returns `undefined`, and
159
+ `getAppConfig()` answers `{}`. So one configuration's token never reaches
160
+ another's URL. Build anything that holds
161
+ on to them, such as a MapLibre map or a geocoder, from the context's current
162
+ values, and rebuild it when `client`, `getToken` or `apiUrl` changes, as the
163
+ [complete example](#complete-example-with-maplibre) does. The same happens
164
+ without a `configKey` when `getConfig` starts answering with a different
165
+ `apiUrl`.
166
+
140
167
  ### useLocationClient
141
168
 
142
169
  Hook to access the location client in any component.
143
170
 
144
171
  ```tsx
145
- const { client, getToken, loading, error } = useLocationClient()
172
+ const { client, getToken, apiUrl, loading, error } = useLocationClient()
146
173
  ```
147
174
 
148
175
  **Returns:**
149
176
 
150
177
  - `client` (`LocationClient | null`) — The location client. Not a bare `GeoPlacesClient`: the provider wraps it so `send()` and `verifyAddress()` refresh the token first when they need to, and retry once if the API rejects it.
151
- - `getToken` (`() => string | undefined`) — Returns the current token. Useful for direct API calls (e.g., map style fetch).
152
- - `loading` (`boolean`) — Whether the client is initializing.
153
- - `error` (`string | null`) — Error message if initialization or token refresh failed.
178
+ - `getToken` (`() => string | undefined`) — Returns the current token, for requests the client does not make itself, such as a map's style, tiles and glyphs. It is available before the first token arrives and stays the same function afterwards, so a map built early reads the token once it lands. It returns `undefined` once `configKey` changes.
179
+ - `apiUrl` (`string | null`) — The API `client` talks to, from the same `getConfig` answer as the token. Build map URLs from it rather than restating the URL. `null` until a configuration has loaded.
180
+ - `loading` (`boolean`) — Whether the client is initializing. `true` again while a new `configKey` loads.
181
+ - `error` (`string | null`) — Error message if initialization or a token refresh failed. The provider keeps retrying, and `error` returns to `null` on the first success (see [Token Refresh](#token-refresh)).
154
182
 
155
183
  **Throws:** Error if used outside `LocationClientProvider`.
156
184
 
@@ -176,11 +204,46 @@ The provider owns the token lifecycle. There is nothing to manage manually.
176
204
  package's peer range admits has it.
177
205
  6. Concurrent refreshes are deduplicated — everything waiting shares one call to
178
206
  `getConfig`.
179
-
180
- A refresh that fails is reported as `error` from `useLocationClient()`, and
181
- rejects the `send()` or `verifyAddress()` that triggered it — with the refresh error rather than a
182
- 401, so the cause reads as the token endpoint being unreachable and not as the
183
- API refusing you.
207
+ 7. A failed `getConfig` is retried on the provider's own timer, backing off
208
+ exponentially from 1 to 30 seconds, with jitter. When `getConfig` rejects
209
+ with an error that has `retryAfterMs` (milliseconds), it waits that long
210
+ instead. Until then nothing else asks, neither a map reading its token for
211
+ every tile nor a `send()`. The tab coming back into view, or the browser
212
+ coming back online, retries at once, unless the wait was a `retryAfterMs`.
213
+
214
+ A Server Action, as in the Quick Start, cannot pass `retryAfterMs` on:
215
+ React sends a thrown error to the browser without its own fields. To honour
216
+ a token route's `Retry-After`, call the route from the browser and throw it
217
+ there:
218
+
219
+ ```ts
220
+ async function getConfig() {
221
+ const res = await fetch('/api/location-token')
222
+ if (!res.ok) {
223
+ const retryAfter = Number(res.headers.get('retry-after'))
224
+ throw Object.assign(new Error(`token route answered ${res.status}`), {
225
+ retryAfterMs: retryAfter > 0 ? retryAfter * 1000 : undefined,
226
+ })
227
+ }
228
+ return res.json()
229
+ }
230
+ ```
231
+
232
+ 8. That includes the first call. A page whose first `getConfig` fails gets its
233
+ client when a retry succeeds, without a reload.
234
+ 9. A token that arrives already stale by the browser's clock, because that
235
+ clock runs ahead of the server's, is not asked for again at once: the
236
+ server would hand back the same one. The provider waits, longer each time,
237
+ and sends with the token it has. A 401 still replaces it straight away.
238
+
239
+ A refresh that fails is reported as `error` from `useLocationClient()` until a
240
+ retry succeeds. Meanwhile every request goes out with the token in hand for as
241
+ long as that token is before its own expiry: `send()`, `verifyAddress()` and a
242
+ map's tiles alike, because the API still accepts it. Once it has expired,
243
+ `send()` and `verifyAddress()` reject with the refresh error rather than a 401,
244
+ so the cause reads as the token endpoint being unreachable, not as the API
245
+ refusing you. A token the API refuses (a 401) is never sent again: that
246
+ request rejects with the refresh error too.
184
247
 
185
248
  ## Complete Example with MapLibre
186
249
 
@@ -200,24 +263,24 @@ import {
200
263
  import maplibregl from 'maplibre-gl'
201
264
  import MaplibreGeocoder from '@maplibre/maplibre-gl-geocoder'
202
265
 
203
- const API_URL = process.env.NEXT_PUBLIC_LOCATION_API_URL!
204
-
205
266
  export default function MapComponent() {
206
267
  const mapContainer = useRef<HTMLDivElement>(null)
207
268
  const map = useRef<maplibregl.Map | null>(null)
208
269
  const [mapInstance, setMapInstance] = useState<maplibregl.Map | null>(null)
209
270
  const [mapError, setMapError] = useState<string | null>(null)
210
271
  const [language, setLanguage] = useState('en')
211
- const { client, getToken, loading, error } = useLocationClient()
272
+ const { client, getToken, apiUrl, loading, error } = useLocationClient()
212
273
 
213
274
  // Keeps map labels in sync with language — reapplies after every setStyle() call
214
275
  useMapLanguage(mapInstance, language)
215
276
 
216
277
  useEffect(() => {
217
- if (!mapContainer.current || map.current || loading || !client) return
278
+ if (!mapContainer.current || map.current || loading || !client || !apiUrl)
279
+ return
218
280
  ;(async () => {
219
- // Fetch the style with the language baked into the descriptor
220
- const style = await fetchMapStyle(API_URL, 'Standard', getToken, {
281
+ // Fetch the style with the language baked into the descriptor. The URL
282
+ // comes from the context with the token, so the two always belong together.
283
+ const style = await fetchMapStyle(apiUrl, 'Standard', getToken, {
221
284
  colorScheme: 'Light',
222
285
  language,
223
286
  })
@@ -227,7 +290,7 @@ export default function MapComponent() {
227
290
  style,
228
291
  center: [-123.12, 49.28],
229
292
  zoom: 10,
230
- transformRequest: createTransformRequest(API_URL, getToken),
293
+ transformRequest: createTransformRequest(apiUrl, getToken),
231
294
  })
232
295
  // Held at once, so the catch and the cleanup below can remove it
233
296
  map.current = instance
@@ -262,7 +325,7 @@ export default function MapComponent() {
262
325
  setMapInstance(null)
263
326
  }
264
327
  }
265
- }, [client, getToken, loading])
328
+ }, [client, getToken, apiUrl, loading])
266
329
 
267
330
  if (error) return <div>Error: {error}</div>
268
331
  if (mapError) return <div>Map unavailable: {mapError}</div>
@@ -277,7 +340,7 @@ one refuses the style request with 403 `FeatureNotEntitledException`, which the
277
340
  `catch` above puts on screen. 3D terrain and buildings need the `terrain` and `buildings` plan features:
278
341
 
279
342
  ```tsx
280
- const style = await fetchMapStyle(API_URL, 'Standard', getToken, {
343
+ const style = await fetchMapStyle(apiUrl, 'Standard', getToken, {
281
344
  colorScheme: 'Light',
282
345
  terrain: 'Terrain3D',
283
346
  buildings: 'Buildings3D',
@@ -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 {};